diff --git a/backend/src/domus/application/household_administration_queries.py b/backend/src/domus/application/household_administration_queries.py new file mode 100644 index 0000000..bbb1372 --- /dev/null +++ b/backend/src/domus/application/household_administration_queries.py @@ -0,0 +1,38 @@ +"""Permitted public presentation and exclusively current-owner private preference reads.""" + +from dataclasses import dataclass + +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.identity import HumanIdentity, Permission +from domus.domain.member_profiles import MemberPreferences, MemberPresentation +from domus.domain.storage import validate_identifier + + +@dataclass(frozen=True, slots=True) +class HouseholdAdministrationQueries: + transactions: Transactions + human: HumanIdentity + household_id: str = "home" + + def __post_init__(self) -> None: + validate_identifier(self.household_id) + + 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) + value = unit.presentations.get(self.household_id, member_id) + if value is None: + raise NotFound("member_missing") + return value + + def preferences(self) -> MemberPreferences: + with self.transactions.read() as unit: + member = preference_owner(unit, self.human, self.household_id) + value = unit.preferences.get(self.household_id, member.member_id) + if value is None: + raise StorageFailure("invalid_member_profile_record") + return value diff --git a/backend/src/domus/application/household_queries.py b/backend/src/domus/application/household_queries.py index 3b90bf6..99934b8 100644 --- a/backend/src/domus/application/household_queries.py +++ b/backend/src/domus/application/household_queries.py @@ -4,6 +4,11 @@ from typing import Literal, cast from domus.application.authorization import HouseholdAccess +from domus.application.member_profile_access import ( + ALIAS_ACTION, + PREFERENCES_ACTION, + require_profile_original, +) from domus.application.ports import Transactions, UnitOfWork from domus.domain.errors import Conflict, Forbidden, NotFound, StorageFailure from domus.domain.identity import ( @@ -17,7 +22,7 @@ allows, effective_role, ) -from domus.domain.storage import Household, Receipt, validate_identifier +from domus.domain.storage import CommandKey, Household, Receipt, validate_identifier _ADMINISTRATION_SCOPE = ( Permission.HOUSEHOLD_READ, @@ -36,6 +41,9 @@ "policy.update": Permission.POLICY_MANAGE, "recovery.complete": Permission.POLICY_MANAGE, } +ADMINISTRATION_ACTIONS = frozenset( + {"household.update", "member.update", "policy.update", ALIAS_ACTION, PREFERENCES_ACTION} +) @dataclass(frozen=True, slots=True) @@ -157,31 +165,51 @@ def policy(self) -> HouseholdPolicy: self._require(unit, Permission.POLICY_MANAGE) return cast(HouseholdPolicy, unit.policies.get(self.household_id)) + def _operation_access(self, unit: UnitOfWork) -> None: + member = unit.members.by_user(self.household_id, self.human.ha_user_id) + HouseholdAccess(self.human).require( + unit, + self.household_id, + Permission.OPERATIONS_READ, + subject_member_id=member.member_id if member is not None else None, + ) + + def _operation_view(self, unit: UnitOfWork, receipt: Receipt) -> OperationView: + if not require_profile_original( + unit, self.human, self.household_id, receipt + ) and receipt.command.action not in ( + "member.enroll", + "recovery.request", + "recovery.cancel", + ): + original = _ORIGINAL_PERMISSION.get(receipt.command.action) + if original is None: + raise Forbidden("permission_denied") + self._require(unit, original) + delivered = unit.outbox.delivered(receipt.operation_id) + if delivered is None: + raise StorageFailure("invalid_operation_record") + return OperationView(receipt, delivered) + def operation(self, operation_id: str) -> OperationView: validate_identifier(operation_id) with self.transactions.read() as unit: - member = unit.members.by_user(self.household_id, self.human.ha_user_id) - HouseholdAccess(self.human).require( - unit, - self.household_id, - Permission.OPERATIONS_READ, - subject_member_id=member.member_id if member is not None else None, - ) + self._operation_access(unit) receipt = unit.receipts.get_operation( self.household_id, self.human.ha_user_id, operation_id ) if receipt is None: raise NotFound("operation_missing") - if receipt.command.action not in ( - "member.enroll", - "recovery.request", - "recovery.cancel", - ): - original = _ORIGINAL_PERMISSION.get(receipt.command.action) - if original is None: - raise Forbidden("permission_denied") - self._require(unit, original) - delivered = unit.outbox.delivered(receipt.operation_id) - if delivered is None: - raise StorageFailure("invalid_operation_record") - return OperationView(receipt, delivered) + return self._operation_view(unit, receipt) + + def administration_request(self, action: str, key: str) -> OperationView: + """Read an owned original even if its response/operation ID was lost; never execute it.""" + if action not in ADMINISTRATION_ACTIONS: + raise Forbidden("permission_denied") + command = CommandKey(self.household_id, self.human.ha_user_id, action, key) + with self.transactions.read() as unit: + self._operation_access(unit) + receipt = unit.receipts.get(command) + if receipt is None: + raise NotFound("operation_missing") + return self._operation_view(unit, receipt) diff --git a/backend/src/domus/application/household_roster.py b/backend/src/domus/application/household_roster.py new file mode 100644 index 0000000..985806d --- /dev/null +++ b/backend/src/domus/application/household_roster.py @@ -0,0 +1,132 @@ +"""Current-authorized roster/directory projections fence bounded provider reads outside SQLite.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast + +from domus.application.authorization import HouseholdAccess +from domus.application.household_queries import HouseholdQueries, MemberView +from domus.application.identity_ports import ReadHuman +from domus.application.member_source_ports import MemberProfileSource +from domus.application.ports import Transactions, UnitOfWork +from domus.domain.errors import Conflict, IdentityRejected, StorageFailure +from domus.domain.identity import HouseholdPolicy, HumanIdentity, Member, Permission, allows +from domus.domain.member_profiles import MemberPresentation +from domus.domain.member_sources import ( + MemberSourceSnapshot, + ProfileDirectory, + SourceState, +) +from domus.domain.storage import validate_identifier + + +@dataclass(frozen=True, slots=True) +class RosterMetadata: + state: SourceState + name: str | None + + +@dataclass(frozen=True, slots=True) +class RosterMember: + member: MemberView + presentation: MemberPresentation + metadata: RosterMetadata + + +@dataclass(frozen=True, slots=True) +class RosterView: + members: tuple[RosterMember, ...] + directory_state: SourceState + + +@dataclass(frozen=True, slots=True) +class _Identity: + actor: Member + policy: HouseholdPolicy + members: tuple[Member, ...] + + +@dataclass(frozen=True, slots=True) +class HouseholdRosterQueries: + transactions: Transactions + actor: ReadHuman + source: MemberProfileSource + household_id: str = "home" + + def __post_init__(self) -> None: + validate_identifier(self.household_id) + + def _identity( + self, unit: UnitOfWork, human: HumanIdentity, permission: Permission + ) -> _Identity: + actor = HouseholdAccess(human).require(unit, self.household_id, permission) + members = unit.members.list(self.household_id) + if len(members) > 256: + raise StorageFailure("invalid_identity_record") + return _Identity( + actor, cast(HouseholdPolicy, unit.policies.get(self.household_id)), members + ) + + def _read[T]( + self, + permission: Permission, + project: Callable[[UnitOfWork, HumanIdentity, MemberSourceSnapshot, _Identity], T], + ) -> T: + human = self.actor.read() + with self.transactions.read() as unit: + before = self._identity(unit, human, permission) + sources = self.source.read() + if not isinstance(sources, MemberSourceSnapshot): + raise StorageFailure("invalid_member_source_record") + if sources.subject != human.ha_user_id: + raise IdentityRejected("identity_mismatch") + current = self.actor.read() + if current.ha_user_id != human.ha_user_id: + raise IdentityRejected("identity_mismatch") + with self.transactions.read() as unit: + after = self._identity(unit, current, permission) + if after != before: + raise Conflict("identity_state_changed") + return project(unit, current, sources, after) + + def roster(self) -> RosterView: + def project( + unit: UnitOfWork, human: HumanIdentity, sources: MemberSourceSnapshot, state: _Identity + ) -> RosterView: + privileged = allows( + human, state.actor, state.policy, Permission.MEMBERS_MANAGE, self.household_id + ) + existing = HouseholdQueries(self.transactions, human, self.household_id) + result = [] + for member in state.members: + presentation = unit.presentations.get(self.household_id, member.member_id) + if presentation is None: + raise StorageFailure("invalid_member_profile_record") + metadata = sources.profile(member.ha_user_id) + name = metadata.profile.name if metadata.profile is not None else None + result.append( + RosterMember( + existing._view(member, privileged=privileged), + presentation, + RosterMetadata( + SourceState.UNAVAILABLE + if metadata.state is SourceState.AVAILABLE and name is None + else metadata.state, + name, + ), + ) + ) + return RosterView(tuple(result), sources.directory.state) + + return self._read(Permission.MEMBERS_READ, project) + + def directory(self) -> ProfileDirectory: + def project( + unit: UnitOfWork, human: HumanIdentity, sources: MemberSourceSnapshot, state: _Identity + ) -> ProfileDirectory: + return ProfileDirectory( + sources.directory.state, + tuple(profile for profile in sources.directory.profiles if profile.eligible), + ) + + return self._read(Permission.POLICY_MANAGE, project) diff --git a/backend/src/domus/application/identity_ports.py b/backend/src/domus/application/identity_ports.py index a5f1cf6..86ddc8a 100644 --- a/backend/src/domus/application/identity_ports.py +++ b/backend/src/domus/application/identity_ports.py @@ -13,6 +13,12 @@ class CurrentHuman(Protocol): def current(self) -> HumanIdentity: ... +class ReadHuman(Protocol): + """Current verified read proof, including only the existing bounded outage lease.""" + + def read(self) -> HumanIdentity: ... + + class HumanDirectory(Protocol): """Resolve one exact, currently eligible human through a configured authority.""" diff --git a/backend/src/domus/application/member_profile_access.py b/backend/src/domus/application/member_profile_access.py new file mode 100644 index 0000000..db883b7 --- /dev/null +++ b/backend/src/domus/application/member_profile_access.py @@ -0,0 +1,90 @@ +"""Current public-profile authority and owner-only original preference disclosure.""" + +from dataclasses import dataclass + +from domus.application.authorization import HouseholdAccess +from domus.application.ports import UnitOfWork +from domus.domain.errors import Forbidden, InvalidInput, StorageFailure +from domus.domain.identity import HumanIdentity, Member, Permission, allows +from domus.domain.storage import CommandKey, Receipt, validate_identifier + +ALIAS_ACTION = "member.presentation.update" +PREFERENCES_ACTION = "member.preferences.update" + + +def preference_owner( + unit: UnitOfWork, human: HumanIdentity, household_id: str, *, write: bool = False +) -> Member: + member = unit.members.by_user(household_id, human.ha_user_id) + return HouseholdAccess(human).require( + unit, + household_id, + Permission.PREFERENCES_WRITE if write else Permission.PREFERENCES_READ, + subject_member_id=member.member_id if member is not None else None, + ) + + +def require_alias( + unit: UnitOfWork, human: HumanIdentity, household_id: str, subject_member_id: str +) -> None: + member = unit.members.by_user(household_id, human.ha_user_id) + policy = unit.policies.get(household_id) + if member is None or policy is None: + raise Forbidden("permission_denied") + if not ( + allows(human, member, policy, Permission.MEMBERS_MANAGE, household_id) + or allows( + human, + member, + policy, + Permission.PREFERENCES_WRITE, + household_id, + subject_member_id=subject_member_id, + ) + ): + raise Forbidden("permission_denied") + + +@dataclass(frozen=True, slots=True) +class ProfileAuthorization: + human: HumanIdentity + action: str + subject_member_id: str | None = None + + def require(self, command: CommandKey, unit: UnitOfWork) -> None: + if command.actor_id != self.human.ha_user_id or command.action != self.action: + raise Forbidden("permission_denied") + if self.action == ALIAS_ACTION and self.subject_member_id is not None: + require_alias(unit, self.human, command.household_id, self.subject_member_id) + elif self.action == PREFERENCES_ACTION and self.subject_member_id is None: + preference_owner(unit, self.human, command.household_id, write=True) + else: + raise Forbidden("permission_denied") + + +def _original_subject(receipt: Receipt) -> str: + try: + value = receipt.result.decode().get("member_id") + if not isinstance(value, str): + raise InvalidInput("invalid_member_id") + validate_identifier(value) + return value + except InvalidInput: + raise StorageFailure("invalid_operation_record") from None + + +def require_profile_original( + unit: UnitOfWork, human: HumanIdentity, household_id: str, receipt: Receipt +) -> bool: + """True requires current profile authority; False grants nothing to another action.""" + if receipt.command.action not in (ALIAS_ACTION, PREFERENCES_ACTION): + return False + if receipt.command.household_id != household_id or receipt.command.actor_id != human.ha_user_id: + raise Forbidden("permission_denied") + if receipt.command.action == PREFERENCES_ACTION: + member = preference_owner(unit, human, household_id, write=True) + if _original_subject(receipt) != member.member_id: + raise Forbidden("permission_denied") + else: + require_alias(unit, human, household_id, _original_subject(receipt)) + return True diff --git a/backend/src/domus/application/member_profile_commands.py b/backend/src/domus/application/member_profile_commands.py new file mode 100644 index 0000000..232135d --- /dev/null +++ b/backend/src/domus/application/member_profile_commands.py @@ -0,0 +1,169 @@ +"""Revisioned profile effects and private originals commit under current authority.""" + +from dataclasses import dataclass, replace + +from domus.application.commands import AtomicCommands, Change +from domus.application.identity_ports import CurrentHuman +from domus.application.member_profile_access import ( + ALIAS_ACTION, + PREFERENCES_ACTION, + ProfileAuthorization, + preference_owner, +) +from domus.application.ports import Clock, Identifiers, Transactions, UnitOfWork +from domus.domain.errors import Conflict, StorageFailure +from domus.domain.identity import HumanIdentity +from domus.domain.member_profiles import MemberLocale, MemberPreferences, MemberPresentation +from domus.domain.storage import CommandKey, JsonDocument, Receipt, validate_identifier + + +@dataclass(frozen=True, slots=True) +class AliasUpdate: + member_id: str + expected_revision: int + alias: str | None + + def __post_init__(self) -> None: + value = MemberPresentation(self.member_id, "validation", self.alias, self.expected_revision) + object.__setattr__(self, "alias", value.alias) + + def document(self) -> JsonDocument: + return JsonDocument.from_value( + { + "member_id": self.member_id, + "expected_revision": self.expected_revision, + "alias": self.alias, + } + ) + + +@dataclass(frozen=True, slots=True) +class PreferencesUpdate: + expected_revision: int + locale: MemberLocale | None + routine_participation_summaries: bool + + def __post_init__(self) -> None: + MemberPreferences( + "validation", + "validation", + self.locale, + self.routine_participation_summaries, + self.expected_revision, + ) + + def document(self) -> JsonDocument: + return JsonDocument.from_value( + { + "expected_revision": self.expected_revision, + "locale": self.locale.value if self.locale is not None else None, + "routine_participation_summaries": self.routine_participation_summaries, + } + ) + + +@dataclass +class MemberProfileCommands: + transactions: Transactions + actor: CurrentHuman + clock: Clock + identifiers: Identifiers + household_id: str = "home" + + def __post_init__(self) -> None: + validate_identifier(self.household_id) + + def _commands( + self, human: HumanIdentity, action: str, subject_member_id: str | None = None + ) -> AtomicCommands: + return AtomicCommands( + self.transactions, + ProfileAuthorization(human, action, subject_member_id), + self.clock, + self.identifiers, + ) + + def update_alias(self, key: str, request: AliasUpdate) -> Receipt: + human = self.actor.current() + command = CommandKey(self.household_id, human.ha_user_id, ALIAS_ACTION, key) + + def apply(unit: UnitOfWork) -> Change: + current = unit.presentations.get(self.household_id, request.member_id) + if current is None: + raise Conflict("member_missing") + if current.revision != request.expected_revision: + raise Conflict("revision_conflict") + changed = replace(current, alias=request.alias, revision=current.revision + 1) + unit.presentations.save(changed, request.expected_revision) + result = JsonDocument.from_value( + { + "member_id": changed.member_id, + "revision": changed.revision, + "alias": changed.alias, + } + ) + detail = JsonDocument.from_value( + { + "transition": "member.presentation.updated", + "member_id": changed.member_id, + "previous_revision": current.revision, + "revision": changed.revision, + } + ) + return Change( + result, + detail, + JsonDocument.from_value( + {"member_id": changed.member_id, "revision": changed.revision} + ), + ) + + return self._commands(human, ALIAS_ACTION, request.member_id).execute( + command, request.document(), apply + ) + + def update_preferences(self, key: str, request: PreferencesUpdate) -> Receipt: + human = self.actor.current() + command = CommandKey(self.household_id, human.ha_user_id, PREFERENCES_ACTION, key) + + def apply(unit: UnitOfWork) -> Change: + member = preference_owner(unit, human, self.household_id, write=True) + current = unit.preferences.get(self.household_id, member.member_id) + if current is None: + raise StorageFailure("invalid_member_profile_record") + if current.revision != request.expected_revision: + raise Conflict("revision_conflict") + changed = replace( + current, + locale=request.locale, + routine_participation_summaries=request.routine_participation_summaries, + revision=current.revision + 1, + ) + unit.preferences.save(changed, request.expected_revision) + result = JsonDocument.from_value( + { + "member_id": changed.member_id, + "revision": changed.revision, + "locale": changed.locale.value if changed.locale is not None else None, + "routine_participation_summaries": changed.routine_participation_summaries, + "notification_delivery": "unavailable", + } + ) + detail = JsonDocument.from_value( + { + "transition": "member.preferences.updated", + "member_id": changed.member_id, + "previous_revision": current.revision, + "revision": changed.revision, + } + ) + # Shared surfaces contain mutation facts only, never private values or fingerprints. + return Change( + result, + detail, + JsonDocument.from_value( + {"member_id": changed.member_id, "revision": changed.revision} + ), + ) + + return self._commands(human, PREFERENCES_ACTION).execute(command, request.document(), apply) diff --git a/backend/src/domus/application/member_source_ports.py b/backend/src/domus/application/member_source_ports.py new file mode 100644 index 0000000..40a549e --- /dev/null +++ b/backend/src/domus/application/member_source_ports.py @@ -0,0 +1,9 @@ +"""A source is bound to the verified server subject and returns no credential or authority.""" + +from typing import Protocol + +from domus.domain.member_sources import MemberSourceSnapshot + + +class MemberProfileSource(Protocol): + def read(self) -> MemberSourceSnapshot: ... diff --git a/backend/src/domus/composition/household_administration.py b/backend/src/domus/composition/household_administration.py new file mode 100644 index 0000000..fab4847 --- /dev/null +++ b/backend/src/domus/composition/household_administration.py @@ -0,0 +1,70 @@ +"""Reuse a deployment's already bound authority, transactions and original-operation context.""" + +from collections.abc import Callable +from dataclasses import dataclass, field + +from domus.application.household_administration_queries import HouseholdAdministrationQueries +from domus.application.household_queries import HouseholdQueries +from domus.application.household_roster import HouseholdRosterQueries +from domus.application.member_profile_commands import MemberProfileCommands +from domus.application.member_source_ports import MemberProfileSource +from domus.domain.identity import HumanIdentity +from domus.domain.member_sources import ( + MemberSourceSnapshot, + ProfileDirectory, + ProfileResult, + SourceState, +) +from domus.entrypoints.administration_http import AdministrationHTTPContext +from domus.entrypoints.household_http import HouseholdHTTPContext + + +@dataclass(frozen=True, slots=True) +class _ReadProof: + queries: Callable[[], HouseholdQueries] = field(repr=False) + + def read(self) -> HumanIdentity: + return self.queries().human + + +@dataclass(frozen=True, slots=True) +class _UnavailableSource: + actor: _ReadProof = field(repr=False) + + def read(self) -> MemberSourceSnapshot: + return MemberSourceSnapshot( + self.actor.read().ha_user_id, + ProfileDirectory(SourceState.UNAVAILABLE), + ProfileResult(SourceState.UNAVAILABLE), + ) + + +def administration_services( + household: HouseholdHTTPContext, source: MemberProfileSource | None = None +) -> AdministrationHTTPContext: + existing = household.household + + def queries() -> HouseholdAdministrationQueries: + current = household.queries() + return HouseholdAdministrationQueries( + current.transactions, current.human, current.household_id + ) + + reader = _ReadProof(household.queries) + return AdministrationHTTPContext( + queries, + MemberProfileCommands( + existing.transactions, + existing.actor, + existing.clock, + existing.identifiers, + existing.household_id, + ), + household.queries, + HouseholdRosterQueries( + existing.transactions, + reader, + source if source is not None else _UnavailableSource(reader), + existing.household_id, + ), + ) diff --git a/backend/src/domus/composition/ingress.py b/backend/src/domus/composition/ingress.py index 1a33563..715ad58 100644 --- a/backend/src/domus/composition/ingress.py +++ b/backend/src/domus/composition/ingress.py @@ -16,6 +16,7 @@ from domus.application.policy_commands import PolicyCommands from domus.application.recovery_commands import RecoveryCommands, RecoveryQueries from domus.application.task_events import TaskEventSource +from domus.composition.household_administration import administration_services from domus.composition.recurrence import bind_lifespan, recurrence_worker from domus.composition.tasks import task_contexts, task_event_source from domus.domain.errors import DomusError, InvalidInput @@ -30,6 +31,7 @@ from domus.infrastructure.ha_config import HAEndpoint, PublicOrigins from domus.infrastructure.ha_directory import HomeAssistantDirectory from domus.infrastructure.ha_ingress import SupervisorIdentity +from domus.infrastructure.ha_member_profiles import NativeMemberProfiles from domus.infrastructure.ha_websocket import HomeAssistantWebSocket from domus.infrastructure.ingress_sessions import IngressRequest, IngressSessions from domus.infrastructure.owned_storage import OwnedStorage, open_storage, reject_owned_database @@ -129,6 +131,10 @@ def ui_binding(request: Request) -> None: correlation=lambda: "cor_" + entropy.new(), entry=EntryHTTPContext(store.installation, "native", clock, utc, True), task_context=lambda request: tasks(request_actor(request)), + administration_context=lambda request: administration_services( + context(request).household, + NativeMemberProfiles(identity.lookup, identity.transport_token, request_actor(request)), + ), ui_bundle=load_bundle(Path(__file__).resolve().parents[1] / "ui"), ui_binding=ui_binding, ) diff --git a/backend/src/domus/composition/server.py b/backend/src/domus/composition/server.py index 8a348d8..eeae0b7 100644 --- a/backend/src/domus/composition/server.py +++ b/backend/src/domus/composition/server.py @@ -13,10 +13,12 @@ from domus.application.sessions import SessionGuard from domus.application.task_events import TaskEventSource from domus.composition.household import standalone_context +from domus.composition.household_administration import administration_services from domus.composition.recurrence import bind_lifespan, recurrence_worker from domus.composition.tasks import task_contexts, task_event_source from domus.domain.errors import DomusError, InvalidInput from domus.domain.storage import Installation +from domus.entrypoints.administration_http import AdministrationHTTPContext from domus.entrypoints.entry_context import EntryHTTPContext from domus.entrypoints.http import HTTPSettings, create_app from domus.entrypoints.task_http import TaskHTTPContext @@ -29,7 +31,7 @@ from domus.infrastructure.memory_attempts import MemoryLoginAttempts from domus.infrastructure.owned_storage import OwnedStorage, open_storage, reject_owned_database from domus.infrastructure.recurrence_worker import RecurrenceWorker -from domus.infrastructure.session_access import SessionActor +from domus.infrastructure.session_access import SessionActor, SessionMemberProfiles from domus.infrastructure.sqlite import SQLiteDatabase from domus.infrastructure.standalone_sessions import StandaloneIdentity @@ -102,10 +104,19 @@ def _compose( settings = HTTPSettings(config.public_origin, tag, entropy.new().encode("ascii")) guard = SessionGuard(identity, identity, clock) context = None + administration = None tasks = None events = None if store is not None: - context = standalone_context(store, guard, identity, installation) + household_factory = standalone_context(store, guard, identity, installation) + context = household_factory + + def session_administration(session_id: str) -> AdministrationHTTPContext: + return administration_services( + household_factory(session_id), SessionMemberProfiles(identity, session_id) + ) + + administration = session_administration factory = task_contexts(store, installation) events = task_event_source(store, installation) @@ -121,6 +132,7 @@ def session_tasks(session_id: str) -> TaskHTTPContext: correlation=lambda: "cor_" + entropy.new(), entry=EntryHTTPContext(installation, "standalone", clock, SystemClock(), store is not None), household_context=context, + administration_context=administration, task_context=tasks, ui_bundle=load_bundle(Path(__file__).resolve().parents[1] / "ui"), ) diff --git a/backend/src/domus/domain/member_sources.py b/backend/src/domus/domain/member_sources.py new file mode 100644 index 0000000..f70cfab --- /dev/null +++ b/backend/src/domus/domain/member_sources.py @@ -0,0 +1,114 @@ +"""Bounded HA presentation facts are independent of identity and household authority.""" + +from dataclasses import dataclass +from enum import StrEnum +from typing import cast +from unicodedata import category + +from domus.domain.errors import InvalidInput +from domus.domain.storage import validate_identifier + + +class SourceState(StrEnum): + AVAILABLE = "available" + ABSENT = "absent" + DENIED = "denied" + UNAVAILABLE = "unavailable" + + +@dataclass(frozen=True, slots=True) +class HAProfile: + ha_user_id: str + name: str | None + eligible: bool + + def __post_init__(self) -> None: + validate_identifier(self.ha_user_id) + if type(self.eligible) is not bool: + raise InvalidInput("invalid_member_source") + if self.name is None: + return + if not isinstance(self.name, str): + raise InvalidInput("invalid_member_source") + try: + self.name.encode("utf-8") + except UnicodeError: + raise InvalidInput("invalid_member_source") from None + if ( + not self.name.strip() + or len(self.name) > 256 + or any(category(character) == "Cc" for character in self.name) + ): + raise InvalidInput("invalid_member_source") + + +@dataclass(frozen=True, slots=True) +class ProfileResult: + state: SourceState + profile: HAProfile | None = None + + def __post_init__(self) -> None: + if not isinstance(self.state, SourceState): + raise InvalidInput("invalid_member_source") + if self.state is SourceState.AVAILABLE: + if not isinstance(self.profile, HAProfile): + raise InvalidInput("invalid_member_source") + elif self.profile is not None: + raise InvalidInput("invalid_member_source") + + +@dataclass(frozen=True, slots=True) +class ProfileDirectory: + state: SourceState + profiles: tuple[HAProfile, ...] = () + + def __post_init__(self) -> None: + if ( + not isinstance(self.state, SourceState) + or self.state is SourceState.ABSENT + or type(self.profiles) is not tuple + or len(self.profiles) > 256 + or any(not isinstance(profile, HAProfile) for profile in self.profiles) + or len({profile.ha_user_id for profile in self.profiles}) != len(self.profiles) + or (self.state is not SourceState.AVAILABLE and bool(self.profiles)) + ): + raise InvalidInput("invalid_member_source") + + def profile(self, ha_user_id: str) -> ProfileResult: + validate_identifier(ha_user_id) + if self.state is not SourceState.AVAILABLE: + return ProfileResult(self.state) + value = next( + (profile for profile in self.profiles if profile.ha_user_id == ha_user_id), None + ) + return ( + ProfileResult(SourceState.ABSENT) + if value is None + else ProfileResult(SourceState.AVAILABLE, value) + ) + + +@dataclass(frozen=True, slots=True) +class MemberSourceSnapshot: + subject: str + directory: ProfileDirectory + own: ProfileResult + + def __post_init__(self) -> None: + validate_identifier(self.subject) + if not isinstance(self.directory, ProfileDirectory) or not isinstance( + self.own, ProfileResult + ): + raise InvalidInput("invalid_member_source") + if ( + self.own.state is SourceState.AVAILABLE + and cast(HAProfile, self.own.profile).ha_user_id != self.subject + ): + raise InvalidInput("invalid_member_source") + if self.directory.state is SourceState.AVAILABLE and self.own != self.directory.profile( + self.subject + ): + raise InvalidInput("invalid_member_source") + + def profile(self, ha_user_id: str) -> ProfileResult: + return self.own if ha_user_id == self.subject else self.directory.profile(ha_user_id) diff --git a/backend/src/domus/entrypoints/administration_dto.py b/backend/src/domus/entrypoints/administration_dto.py new file mode 100644 index 0000000..b3006af --- /dev/null +++ b/backend/src/domus/entrypoints/administration_dto.py @@ -0,0 +1,97 @@ +"""Closed profile wire values; owner identity is deliberately absent from preference inputs.""" + +from typing import Literal + +from pydantic import Field + +from domus.domain.member_profiles import MemberLocale +from domus.domain.member_sources import SourceState +from domus.entrypoints.household_http import MemberResponse, ReceiptResponse +from domus.entrypoints.http_contract import PositiveRevision, StrictModel + +type AdministrationAction = Literal[ + "household.update", + "member.update", + "policy.update", + "member.presentation.update", + "member.preferences.update", +] + + +class AliasRequest(StrictModel): + expected_revision: PositiveRevision + alias: str | None + + +class PreferencesRequest(StrictModel): + expected_revision: PositiveRevision + locale: MemberLocale | None = Field(strict=False) + routine_participation_summaries: bool + + +class PresentationResponse(StrictModel): + member_id: str + alias: str | None + revision: PositiveRevision + + +class PreferencesResponse(StrictModel): + member_id: str + locale: MemberLocale | None = Field(strict=False) + routine_participation_summaries: bool + revision: PositiveRevision + notification_delivery: Literal["unavailable"] = "unavailable" + + +class RosterMetadataResponse(StrictModel): + state: SourceState = Field(strict=False) + name: str | None + avatar_state: Literal["unavailable"] = "unavailable" + + +class RosterMemberResponse(StrictModel): + member: MemberResponse + presentation: PresentationResponse + metadata: RosterMetadataResponse + + +class RosterResponse(StrictModel): + members: tuple[RosterMemberResponse, ...] + directory_state: Literal["available", "denied", "unavailable"] + + +class DirectoryChoiceResponse(StrictModel): + ha_user_id: str + name: str | None + + +class DirectoryResponse(StrictModel): + state: Literal["available", "denied", "unavailable"] + profiles: tuple[DirectoryChoiceResponse, ...] + + +class AdministrationReceipt(ReceiptResponse): + notification_delivery: Literal["unavailable"] = "unavailable" + + +class AdministrationOperation(AdministrationReceipt): + action: AdministrationAction + command_key: str + event_delivery: Literal["pending", "delivered", "suppressed"] + + +class AdministrationJournal(StrictModel): + namespace: Literal["administration"] = "administration" + version: Literal[1] = 1 + maximum_records: Literal[8] = 8 + maximum_bytes: Literal[65536] = 65536 + lifetime_ms: Literal[3600000] = 3600000 + + +class AdministrationCapabilities(StrictModel): + state: Literal["available"] = "available" + contract_version: Literal[2] = 2 + household_id: str + exact_revisions: Literal["json-integer-int64"] = "json-integer-int64" + actions: tuple[AdministrationAction, ...] + journal: AdministrationJournal = Field(default_factory=AdministrationJournal) diff --git a/backend/src/domus/entrypoints/administration_http.py b/backend/src/domus/entrypoints/administration_http.py new file mode 100644 index 0000000..9504dd4 --- /dev/null +++ b/backend/src/domus/entrypoints/administration_http.py @@ -0,0 +1,149 @@ +"""Protected profile routes use the same session/body boundaries as existing household actions.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Annotated, cast + +from fastapi import APIRouter, Query, Request +from fastapi.params import Depends + +from domus.application.household_administration_queries import HouseholdAdministrationQueries +from domus.application.household_queries import ADMINISTRATION_ACTIONS, HouseholdQueries +from domus.application.household_roster import HouseholdRosterQueries +from domus.application.member_profile_access import ALIAS_ACTION, PREFERENCES_ACTION +from domus.application.member_profile_commands import ( + AliasUpdate, + MemberProfileCommands, + PreferencesUpdate, +) +from domus.domain.storage import Receipt +from domus.entrypoints.administration_dto import ( + AdministrationAction, + AdministrationCapabilities, + AdministrationOperation, + AdministrationReceipt, + AliasRequest, + DirectoryResponse, + PreferencesRequest, + PreferencesResponse, + PresentationResponse, + RosterResponse, +) +from domus.entrypoints.household_http import _EmptyQuery, _JSONRoute, _key, _receipt + + +@dataclass(frozen=True, slots=True) +class AdministrationHTTPContext: + queries: Callable[[], HouseholdAdministrationQueries] + commands: MemberProfileCommands + operations: Callable[[], HouseholdQueries] + roster: HouseholdRosterQueries + + +def _profile_receipt(receipt: Receipt, *, profile: bool = True) -> AdministrationReceipt: + result = _receipt(receipt) + if not receipt.historical and profile: + result.message = "The profile change was saved. Notification delivery is unavailable." + result.action_required = ( + "Review current state. Keep the original request key and operation ID." + ) + return AdministrationReceipt(**result.model_dump()) + + +def create_administration_router( + context: Callable[[Request], AdministrationHTTPContext], *, security: Depends +) -> APIRouter: + router = APIRouter( + prefix="/api/v1/administration", dependencies=[security], route_class=_JSONRoute + ) + write_contract = { + "parameters": [ + {"name": name, "in": "header", "required": True, "schema": {"type": "string"}} + for name in ("Idempotency-Key", "Origin", "X-CSRF-Token") + ] + } + + @router.get("/capabilities", response_model=AdministrationCapabilities) + def capabilities( + request: Request, query: Annotated[_EmptyQuery, Query()] + ) -> AdministrationCapabilities: + # Available routes are not a caller grant or acceptance of the complete workspace. + operations = context(request).operations() + operations.access() + return AdministrationCapabilities( + household_id=operations.household_id, + actions=cast(tuple[AdministrationAction, ...], tuple(sorted(ADMINISTRATION_ACTIONS))), + ) + + @router.get("/preferences", response_model=PreferencesResponse) + def preferences( + request: Request, query: Annotated[_EmptyQuery, Query()] + ) -> PreferencesResponse: + return PreferencesResponse.model_validate(context(request).queries().preferences()) + + @router.get("/roster", response_model=RosterResponse) + def roster(request: Request, query: Annotated[_EmptyQuery, Query()]) -> RosterResponse: + return RosterResponse.model_validate(context(request).roster.roster()) + + @router.get("/directory", response_model=DirectoryResponse) + def directory(request: Request, query: Annotated[_EmptyQuery, Query()]) -> DirectoryResponse: + return DirectoryResponse.model_validate(context(request).roster.directory()) + + @router.get("/members/{member_id}/presentation", response_model=PresentationResponse) + def presentation( + member_id: str, request: Request, query: Annotated[_EmptyQuery, Query()] + ) -> PresentationResponse: + return PresentationResponse.model_validate( + context(request).queries().presentation(member_id) + ) + + @router.put("/preferences", response_model=AdministrationReceipt, openapi_extra=write_contract) + def update_preferences( + request: Request, body: PreferencesRequest, query: Annotated[_EmptyQuery, Query()] + ) -> AdministrationReceipt: + receipt = context(request).commands.update_preferences( + _key(request), + PreferencesUpdate( + body.expected_revision, body.locale, body.routine_participation_summaries + ), + ) + return _profile_receipt(receipt) + + @router.put( + "/members/{member_id}/presentation", + response_model=AdministrationReceipt, + openapi_extra=write_contract, + ) + def update_presentation( + member_id: str, request: Request, body: AliasRequest, query: Annotated[_EmptyQuery, Query()] + ) -> AdministrationReceipt: + return _profile_receipt( + context(request).commands.update_alias( + _key(request), AliasUpdate(member_id, body.expected_revision, body.alias) + ) + ) + + @router.get("/requests/{action}/{key}", response_model=AdministrationOperation) + def original_request( + action: AdministrationAction, + key: str, + request: Request, + query: Annotated[_EmptyQuery, Query()], + ) -> AdministrationOperation: + result = context(request).operations().administration_request(action, key) + return AdministrationOperation( + **_profile_receipt( + result.receipt, profile=action in (ALIAS_ACTION, PREFERENCES_ACTION) + ).model_dump(), + action=action, + command_key=key, + event_delivery=( + "delivered" + if result.delivered + else "suppressed" + if result.receipt.historical + else "pending" + ), + ) + + return router diff --git a/backend/src/domus/entrypoints/household_http.py b/backend/src/domus/entrypoints/household_http.py index 4e436f5..e3391d7 100644 --- a/backend/src/domus/entrypoints/household_http.py +++ b/backend/src/domus/entrypoints/household_http.py @@ -27,7 +27,7 @@ Role, ) from domus.domain.storage import JsonDocument, JsonValue, Receipt -from domus.entrypoints.http_contract import StrictModel +from domus.entrypoints.http_contract import PositiveRevision, StrictModel @dataclass(frozen=True, slots=True) @@ -82,7 +82,7 @@ class SetupRequest(StrictModel): class HouseholdUpdateRequest(StrictModel): - expected_revision: int = Field(ge=1, le=2**63 - 1) + expected_revision: PositiveRevision name: str = Field(min_length=1, max_length=160) time_zone: str = Field(min_length=1, max_length=128) @@ -92,7 +92,7 @@ class EnrollmentRequest(StrictModel): class RecoveryTransitionRequest(StrictModel): - expected_revision: int = Field(ge=1, le=2**63 - 1) + expected_revision: PositiveRevision class RecoveryResponse(StrictModel): @@ -111,14 +111,14 @@ class RecoveryResponse(StrictModel): class MemberUpdateRequest(StrictModel): - expected_revision: int = Field(ge=1, le=2**63 - 1) + expected_revision: PositiveRevision role: Role = Field(strict=False) status: MemberStatus = Field(strict=False) exceptions: list[PermissionDecision] = Field(default_factory=list, max_length=256) class PolicyUpdateRequest(StrictModel): - expected_revision: int = Field(ge=1, le=2**63 - 1) + expected_revision: PositiveRevision mapping_mode: MappingMode = Field(strict=False) target_ha_user_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$") adjustments: list[ProfileDecision] = Field(default_factory=list, max_length=256) diff --git a/backend/src/domus/entrypoints/http.py b/backend/src/domus/entrypoints/http.py index 560c359..113639a 100644 --- a/backend/src/domus/entrypoints/http.py +++ b/backend/src/domus/entrypoints/http.py @@ -26,11 +26,16 @@ InvalidInput, Unauthenticated, ) +from domus.entrypoints.administration_http import ( + AdministrationHTTPContext, + create_administration_router, +) from domus.entrypoints.bounded_http import BoundedHTTP from domus.entrypoints.entry_context import CONTEXT_HEADER, EntryContextResponse, EntryHTTPContext from domus.entrypoints.household_http import HouseholdHTTPContext, create_household_router from domus.entrypoints.http_contract import ErrorResponse, StrictModel from domus.entrypoints.http_errors import classify +from domus.entrypoints.openapi import DomusAPI from domus.entrypoints.task_http import TaskHTTPContext, create_task_router from domus.entrypoints.ui_bundle import UIBundle from domus.entrypoints.ui_http import attach_ui @@ -234,10 +239,11 @@ def create_app( correlation: Callable[[], str], entry: EntryHTTPContext, household_context: Callable[[str], HouseholdHTTPContext] | None = None, + administration_context: Callable[[str], AdministrationHTTPContext] | None = None, task_context: Callable[[str], TaskHTTPContext] | None = None, ui_bundle: UIBundle | None = None, ) -> FastAPI: - app = FastAPI( + app = DomusAPI( title="Domus", version="0.0.0", docs_url=None, @@ -410,6 +416,15 @@ def context(request: Request) -> HouseholdHTTPContext: return factory(cast(str, _cookie(request.scope, settings.session_cookie))) app.include_router(create_household_router(context, security=session_security)) + if administration_context is not None: + administration_factory = administration_context + + def administration(request: Request) -> AdministrationHTTPContext: + return administration_factory( + cast(str, _cookie(request.scope, settings.session_cookie)) + ) + + app.include_router(create_administration_router(administration, security=session_security)) if task_context is not None: task_factory = task_context diff --git a/backend/src/domus/entrypoints/http_contract.py b/backend/src/domus/entrypoints/http_contract.py index 60d4004..1d1189b 100644 --- a/backend/src/domus/entrypoints/http_contract.py +++ b/backend/src/domus/entrypoints/http_contract.py @@ -1,8 +1,25 @@ """Common strict wire models are separate from application and domain records.""" -from typing import Literal +from typing import Annotated, Literal -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field + +PositiveRevision = Annotated[ + int, + Field( + ge=1, + le=2**63 - 1, + json_schema_extra={"minimum": 1, "maximum": 2**63 - 1, "format": "int64"}, + ), +] +NonnegativeRevision = Annotated[ + int, + Field( + ge=0, + le=2**63 - 1, + json_schema_extra={"minimum": 0, "maximum": 2**63 - 1, "format": "int64"}, + ), +] class StrictModel(BaseModel): diff --git a/backend/src/domus/entrypoints/http_errors.py b/backend/src/domus/entrypoints/http_errors.py index cb2f17b..a188fc7 100644 --- a/backend/src/domus/entrypoints/http_errors.py +++ b/backend/src/domus/entrypoints/http_errors.py @@ -50,6 +50,8 @@ "protected_permission", "invalid_profile_rule", "invalid_member", + "invalid_member_alias", + "invalid_member_preferences", "invalid_permission_rules", "invalid_profile_rules", "invalid_idempotency_key", diff --git a/backend/src/domus/entrypoints/native_http.py b/backend/src/domus/entrypoints/native_http.py index 51d6759..3cf63c3 100644 --- a/backend/src/domus/entrypoints/native_http.py +++ b/backend/src/domus/entrypoints/native_http.py @@ -15,6 +15,10 @@ from domus.application.identity_ports import LeaseClock from domus.application.sessions import SessionStatus from domus.domain.errors import DomusError, Forbidden, InvalidInput +from domus.entrypoints.administration_http import ( + AdministrationHTTPContext, + create_administration_router, +) from domus.entrypoints.bounded_http import BoundedHTTP from domus.entrypoints.entry_context import CONTEXT_HEADER, EntryHTTPContext from domus.entrypoints.household_http import HouseholdHTTPContext, create_household_router @@ -22,6 +26,7 @@ from domus.entrypoints.http_contract import ErrorResponse, StrictModel from domus.entrypoints.http_errors import classify from domus.entrypoints.ingress_boundary import require_ingress_source +from domus.entrypoints.openapi import DomusAPI from domus.entrypoints.task_http import TaskHTTPContext, create_task_router from domus.entrypoints.ui_bundle import UIBundle from domus.entrypoints.ui_http import attach_ui @@ -114,10 +119,11 @@ def create_native_app( correlation: Callable[[], str], entry: EntryHTTPContext | None = None, task_context: Callable[[Request], TaskHTTPContext] | None = None, + administration_context: Callable[[Request], AdministrationHTTPContext] | None = None, ui_bundle: UIBundle | None = None, ui_binding: Callable[[Request], None] | None = None, ) -> FastAPI: - app = FastAPI( + app = DomusAPI( title="Domus native Home Assistant application", version="0.0.0", docs_url=None, @@ -215,6 +221,10 @@ def health() -> dict[str, str]: ) if task_context is not None: app.include_router(create_task_router(task_context, security=Depends(security))) + if administration_context is not None: + app.include_router( + create_administration_router(administration_context, security=Depends(security)) + ) def bind_ui(request: Request) -> None: if ui_binding is not None: diff --git a/backend/src/domus/entrypoints/openapi.py b/backend/src/domus/entrypoints/openapi.py new file mode 100644 index 0000000..82966d0 --- /dev/null +++ b/backend/src/domus/entrypoints/openapi.py @@ -0,0 +1,29 @@ +"""Preserve explicit int64 bounds after the framework's float-valued schema projection.""" + +from typing import Any + +from fastapi import FastAPI + +_INT64_MAXIMUM = 2**63 - 1 + + +def _exact_int64(value: object) -> None: + if isinstance(value, dict): + if ( + value.get("type") == "integer" + and value.get("format") == "int64" + and value.get("maximum") == float(_INT64_MAXIMUM) + ): + value["maximum"] = _INT64_MAXIMUM + for nested in value.values(): + _exact_int64(nested) + elif isinstance(value, list): + for nested in value: + _exact_int64(nested) + + +class DomusAPI(FastAPI): + def openapi(self) -> dict[str, Any]: + schema = super().openapi() + _exact_int64(schema) + return schema diff --git a/backend/src/domus/entrypoints/task_dto.py b/backend/src/domus/entrypoints/task_dto.py index 22eb382..57edb3d 100644 --- a/backend/src/domus/entrypoints/task_dto.py +++ b/backend/src/domus/entrypoints/task_dto.py @@ -25,11 +25,11 @@ VerificationMode, VerificationState, ) -from domus.entrypoints.http_contract import StrictModel +from domus.entrypoints.http_contract import NonnegativeRevision, PositiveRevision, StrictModel Identifier = Annotated[str, Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")] -Revision = Annotated[int, Field(ge=1, le=2**63 - 1)] -TaskTime = Annotated[int, Field(ge=0, le=2**63 - 1)] +Revision = PositiveRevision +TaskTime = NonnegativeRevision WireRole = Annotated[Role, Field(strict=False)] Quantity = Annotated[ str, @@ -164,7 +164,7 @@ class UpdateDefinitionBody(ContentBody): class PolicyBody(StrictModel): - expected_revision: int = Field(ge=0, le=2**63 - 1) + expected_revision: NonnegativeRevision rules: RulesBody diff --git a/backend/src/domus/infrastructure/ha_member_profiles.py b/backend/src/domus/infrastructure/ha_member_profiles.py new file mode 100644 index 0000000..5bb200d --- /dev/null +++ b/backend/src/domus/infrastructure/ha_member_profiles.py @@ -0,0 +1,99 @@ +"""Two fixed bounded reads supply presentation facts; technical credentials never select a human.""" + +from dataclasses import dataclass, field +from typing import cast + +from domus.application.identity_ports import ReadHuman +from domus.domain.errors import Forbidden, IdentityRejected, IdentityUnavailable, InvalidInput +from domus.domain.member_sources import ( + HAProfile, + MemberSourceSnapshot, + ProfileDirectory, + ProfileResult, + SourceState, +) +from domus.domain.storage import JsonObject, JsonValue +from domus.infrastructure.ha_http import OAuthTokens, _secret +from domus.infrastructure.ha_websocket import HomeAssistantWebSocket, _current_human, _user_id + + +def _directory(records: JsonValue) -> ProfileDirectory: + if not isinstance(records, list) or len(records) > 256: + raise InvalidInput("invalid_member_source") + profiles = [] + for record in records: + if not isinstance(record, dict): + raise InvalidInput("invalid_member_source") + active, system = record["is_active"], record["system_generated"] + if type(active) is not bool or type(system) is not bool: + raise InvalidInput("invalid_member_source") + profiles.append( + HAProfile( + _user_id(record["id"]), cast(str | None, record.get("name")), active and not system + ) + ) + return ProfileDirectory(SourceState.AVAILABLE, tuple(profiles)) + + +@dataclass(frozen=True, slots=True) +class HomeAssistantMemberProfiles: + lookup: HomeAssistantWebSocket = field(repr=False) + transport_token: str = field(repr=False) + subject: str + grant: OAuthTokens | None = field(default=None, repr=False) + + def __post_init__(self) -> None: + _secret(self.transport_token) + _user_id(self.subject) + if self.grant is not None and ( + not isinstance(self.grant, OAuthTokens) + or self.grant.access_token != self.transport_token + ): + raise InvalidInput("invalid_member_source") + + def _directory(self) -> ProfileDirectory: + try: + return _directory(self.lookup._request(self.transport_token, "config/auth/list")) + except Forbidden: + return ProfileDirectory(SourceState.DENIED) + except IdentityRejected, IdentityUnavailable, InvalidInput, KeyError: + return ProfileDirectory(SourceState.UNAVAILABLE) + + def _own(self, fallback: SourceState) -> ProfileResult: + if self.grant is None: + return ProfileResult(fallback) + try: + raw = self.lookup._request(self.transport_token, "auth/current_user") + human = _current_human(raw, self.grant.provider) + if human.ha_user_id != self.subject: + raise IdentityRejected("identity_mismatch") + profile = HAProfile( + human.ha_user_id, cast(str | None, cast(JsonObject, raw).get("name")), True + ) + return ProfileResult(SourceState.AVAILABLE, profile) + except Forbidden: + return ProfileResult(SourceState.DENIED) + except IdentityRejected, IdentityUnavailable, InvalidInput: + return ProfileResult(SourceState.UNAVAILABLE) + + def read(self) -> MemberSourceSnapshot: + directory = self._directory() + own = ( + directory.profile(self.subject) + if directory.state is SourceState.AVAILABLE + else self._own(directory.state) + ) + return MemberSourceSnapshot(self.subject, directory, own) + + +@dataclass(frozen=True, slots=True) +class NativeMemberProfiles: + lookup: HomeAssistantWebSocket = field(repr=False) + transport_token: str = field(repr=False) + actor: ReadHuman = field(repr=False) + + def read(self) -> MemberSourceSnapshot: + human = self.actor.read() + return HomeAssistantMemberProfiles( + self.lookup, self.transport_token, human.ha_user_id + ).read() diff --git a/backend/src/domus/infrastructure/ha_websocket.py b/backend/src/domus/infrastructure/ha_websocket.py index 9d1ae78..d130b0d 100644 --- a/backend/src/domus/infrastructure/ha_websocket.py +++ b/backend/src/domus/infrastructure/ha_websocket.py @@ -48,6 +48,27 @@ async def _message(connection: ClientConnection) -> JsonObject: return JsonDocument(message).decode() +def _current_human(result: JsonValue, provider: str) -> HumanIdentity: + """Decode the existing client-grant proof independently of optional presentation fields.""" + try: + if not isinstance(result, dict): + raise InvalidInput("invalid_ha_identity") + user_id = _user_id(result["id"]) + credentials = result["credentials"] + if ( + not isinstance(credentials, list) + or not 1 <= len(credentials) <= 32 + or any(not isinstance(item, dict) for item in credentials) + or not any( + cast(JsonObject, item).get("auth_provider_type") == provider for item in credentials + ) + ): + raise InvalidInput("invalid_ha_identity") + return HumanIdentity(user_id, cast(bool, result["is_admin"]), True, False) + except DomusError, KeyError: + raise IdentityRejected("invalid_ha_protocol") from None + + @dataclass(frozen=True, slots=True) class HomeAssistantWebSocket: endpoint: HAEndpoint @@ -118,25 +139,9 @@ def current_user(self, grant: OAuthTokens) -> HumanIdentity: token and enforces active users during authentication. current_user alone lacks system/active flags; that OAuth provenance is required here. """ - result = self._request(grant.access_token, "auth/current_user") - try: - if not isinstance(result, dict): - raise InvalidInput("invalid_ha_identity") - user_id = _user_id(result["id"]) - credentials = result["credentials"] - if ( - not isinstance(credentials, list) - or not 1 <= len(credentials) <= 32 - or any(not isinstance(item, dict) for item in credentials) - or not any( - cast(JsonObject, item).get("auth_provider_type") == grant.provider - for item in credentials - ) - ): - raise InvalidInput("invalid_ha_identity") - return HumanIdentity(user_id, cast(bool, result["is_admin"]), True, False) - except DomusError, KeyError: - raise IdentityRejected("invalid_ha_protocol") from None + return _current_human( + self._request(grant.access_token, "auth/current_user"), grant.provider + ) def directory_user(self, transport_token: str, ha_user_id: str) -> HumanIdentity: """Supervisor's credential only transports the exact subject lookup.""" diff --git a/backend/src/domus/infrastructure/session_access.py b/backend/src/domus/infrastructure/session_access.py index 26e1944..f61e529 100644 --- a/backend/src/domus/infrastructure/session_access.py +++ b/backend/src/domus/infrastructure/session_access.py @@ -5,6 +5,7 @@ from domus.application.sessions import SessionGuard from domus.domain.errors import IdentityRejected from domus.domain.identity import HumanIdentity +from domus.domain.member_sources import MemberSourceSnapshot from domus.infrastructure.standalone_sessions import StandaloneIdentity @@ -33,3 +34,12 @@ def get(self, ha_user_id: str) -> HumanIdentity: def eligible(self, ha_user_ids: tuple[str, ...]) -> tuple[HumanIdentity, ...]: return self.identity.directory(self.session_id, ha_user_ids) + + +@dataclass(frozen=True, slots=True) +class SessionMemberProfiles: + identity: StandaloneIdentity = field(repr=False) + session_id: str = field(repr=False) + + def read(self) -> MemberSourceSnapshot: + return self.identity.member_sources(self.session_id) diff --git a/backend/src/domus/infrastructure/standalone_sessions.py b/backend/src/domus/infrastructure/standalone_sessions.py index 631f0b0..b649513 100644 --- a/backend/src/domus/infrastructure/standalone_sessions.py +++ b/backend/src/domus/infrastructure/standalone_sessions.py @@ -1,6 +1,7 @@ """Private in-memory OAuth credentials bind to server-owned, nonextendable leases.""" import logging +from collections.abc import Callable from dataclasses import dataclass, field from threading import Lock, RLock from urllib.parse import urlencode @@ -11,7 +12,9 @@ from domus.application.sessions import SessionGuard from domus.domain.errors import Conflict, DomusError, IdentityRejected, InvalidInput from domus.domain.identity import HumanIdentity +from domus.domain.member_sources import MemberSourceSnapshot from domus.infrastructure.ha_http import HomeAssistantOAuth, OAuthTokens +from domus.infrastructure.ha_member_profiles import HomeAssistantMemberProfiles from domus.infrastructure.ha_websocket import HomeAssistantWebSocket from domus.infrastructure.memory_sessions import MemorySessions @@ -145,6 +148,20 @@ def directory(self, session_id: str, ha_user_ids: tuple[str, ...]) -> tuple[Huma if not ha_user_ids: return () SessionGuard(self, self, self.clock).write(session_id) + return self._with_credentials( + session_id, lambda tokens: self.socket.directory_users(tokens.access_token, ha_user_ids) + ) + + def member_sources(self, session_id: str) -> MemberSourceSnapshot: + human = SessionGuard(self, self, self.clock).read(session_id) + return self._with_credentials( + session_id, + lambda tokens: HomeAssistantMemberProfiles( + self.socket, tokens.access_token, human.ha_user_id, tokens + ).read(), + ) + + def _with_credentials[T](self, session_id: str, read: Callable[[OAuthTokens], T]) -> T: with self._lock: selected = self._credentials.get(session_id) if selected is None: @@ -153,7 +170,7 @@ def directory(self, session_id: str, ha_user_ids: tuple[str, ...]) -> tuple[Huma with self._lock: if self._credentials.get(session_id) is not selected: raise IdentityRejected("session_changed") - humans = self.socket.directory_users(selected.tokens.access_token, ha_user_ids) + result = read(selected.tokens) with self._lock: lease = self.leases.get(session_id) if self._credentials.get(session_id) is not selected or lease is None: @@ -161,7 +178,7 @@ def directory(self, session_id: str, ha_user_ids: tuple[str, ...]) -> tuple[Huma if not lease.valid_at(self.clock.now()): self.remove(session_id) raise IdentityRejected("session_expired") - return humans + return result def shutdown(self) -> tuple[int, int]: """Erase every local credential; at most two bounded remote revoke calls.""" diff --git a/docs/architecture-household-administration.md b/docs/architecture-household-administration.md index 75c25f0..5a3daaa 100644 --- a/docs/architecture-household-administration.md +++ b/docs/architecture-household-administration.md @@ -9,14 +9,13 @@ have separate state and revisions; a public presentation record never includes p An alias is null or trimmed text of 1–80 Unicode code points. UTF-8 and every Unicode Cc control are checked before whitespace normalization. Literal markup stays literal data; the eventual renderer must display it inertly. Null explicitly clears the alias. A missing alias does not invent -an HA display name or avatar. The future profile adapter must supply actual verified presentation -facts or an explicit unavailable result, independently of human proof. +an HA display name or avatar. The source adapter supplies actual verified name facts or an explicit +unavailable result, independently of human proof. Avatar delivery is unavailable. Private preference values are browser-following/null, English or Spanish locale, plus a strict -routine-summary boolean with true as the engineering default. Persisting a preference will not -establish notification delivery. Current owner-only query/command authorization, provider metadata, -HTTP contracts, the separate administration journal and the actual EN/ES workspace remain -to implement. These records do not establish their acceptance. +routine-summary boolean with true as the engineering default. Persisting a preference does not +establish notification delivery. The current application, metadata and HTTP behavior is described +below; browser lifecycle integration and the full EN/ES workspace remain required. The [companion](../specs/household-administration.md) retains all 24 Action-published case rows and adds the omitted independent risks to a 72-case floor. Its original identity and delivery owners @@ -30,8 +29,56 @@ revisioned public presentation and private preference repositories. The records strictly and compares canonical member/household scope and expected revision on updates. New member creation persists both domain defaults atomically; membership updates leave those records untouched. No preference values enter public identity records, audit or outbox through -these storage operations. Application authorization and original-result privacy remain required -before these repositories gain any HTTP or workspace consumer. +these storage operations. + +## Current application and protected HTTP + +[Profile commands](../backend/src/domus/application/member_profile_commands.py) use fresh human +proof outside SQLite, then reread current local grants in the atomic writer. The caller derives +from the established session; preferences have no member or household selector. An alias edit +requires current member-management permission or the owner's applicable preference permission. +It does not activate a pending member or change their private preferences or membership revision. + +[Profile queries](../backend/src/domus/application/household_administration_queries.py) require +current member-read authority for public aliases and current owner-scoped preference-read authority +for preferences. A missing private record is a storage failure, not permission to initialize one. +The [original-result guard](../backend/src/domus/application/member_profile_access.py) runs before +returning profile receipts. Another administrator cannot obtain an owner's original private result +by knowing its request key or operation ID. Revoking the original action permission blocks its +replay and disclosure even if ordinary preference reads remain permitted. + +Effects, immutable results and safe audit/outbox facts commit together. Shared facts include scoped +member/revision/transition evidence, with no private preference values or inferable request/result +fingerprints. Independent writers use the actual database compare-and-swap behavior. Restoration +retains the original result and its historical absent-effect warning without recreating an effect. +Technical outbox acknowledgement is recorded separately from unavailable notification delivery. + +The [HTTP adapter](../backend/src/domus/entrypoints/administration_http.py) reuses the established +body, session, Origin, CSRF and context boundaries in both deployments. Closed DTOs reject owner +injection, ambiguous revisions and unknown fields. A read-only original-request lookup admits only +the five administration actions, derives the actor from the current session and never resends a +mutation. Existing operation-ID lookup uses the same original-result guard. Neither GET requires a +mutation CSRF token or expected revision. Revisions remain exact JSON integers through int64; +browser consumption must preserve their lexical value rather than rounding through Number. + +The [shared OpenAPI projection](../backend/src/domus/entrypoints/openapi.py) restores the exact +maximum of explicitly declared int64 fields after FastAPI projects numeric bounds through float. +Smaller bounds, unmarked integers and approximate number fields remain untouched. The original +framework and actual composed API controls distinguish that schema issue from request validation. + +Browser [administration values](../frontend/src/domain/administration.ts) use canonical decimal +revision text and bounded BigInt arithmetic. The [JSON codec](../frontend/src/adapters/administration-json.ts) +validates duplicate decoded keys, depth, bytes and integer lexemes, then obtains each numeric +value's original source. A numeric string or lookalike object cannot become a wire integer, and an +engine without original-source support fails closed. Fixed profile encoders publish an exact +integer lexeme with only the supported payload fields. These pure values/transport helpers have +browser tests and application-controller consumption; browser workspace integration remains outstanding. + +[Composition](../backend/src/domus/composition/household_administration.py) binds the same +transactions and trusted human adapters as the existing household services. Preview factories +without the administration callback do not register or advertise those routes. Capability metadata +describes wired routes and the closed journal contract; it grants no permission and does not +establish that the browser workspace or journal integration is implemented. The [migration guide](household-administration-migration.md) identifies genuine immutable source8 input, historical migration behavior, independent checkpoint validation, image checks and compatible @@ -40,3 +87,165 @@ rollback. This storage increment does not reduce or complete the retained 72-cas The [checkpoint evidence](evidence/household-administration.json) distinguishes the initial failing test, complete backend execution, per-module/global coverage, independent audit and architecture contracts. No packaged update or owner-HA administration journey is claimed by this checkpoint. + +## Bounded source facts and current roster + +[Source values](../backend/src/domus/domain/member_sources.py) distinguish a complete directory, +confirmed omission, denied access and unavailability. Names are nullable, literal valid UTF-8 of at +most 256 code points, with no Cc controls or whitespace-only labels. An eligibility fact is a strict +boolean and never supplies a household role or grant. The complete source bound is 256 unique +accounts, including ineligible records; overflow/malformed data is not truncated into success. + +The [HA adapter](../backend/src/domus/infrastructure/ha_member_profiles.py) uses the existing fixed, +bounded WebSocket transport. HA2026.10.4 exposes account names through +[own-user source](https://github.com/home-assistant/core/blob/2026.10.4/homeassistant/components/auth/__init__.py#L513) +and the administrator-only +[directory source](https://github.com/home-assistant/core/blob/2026.10.4/homeassistant/components/config/auth.py#L19). +Neither response supplies an account avatar. The adapter makes at most two metadata requests: a +complete directory read and, if it fails, an optional exact-own OAuth read. Native technical grants +never use that fallback as the human. Protocol facts, fake-provider execution and owner HA evidence +remain separate; no live owner directory acceptance is claimed here. + +The standalone identity adapter reuses one private credential gate for directory and metadata +operations. Current read proof permits only the original bounded outage lease; authorizing directory +proofs for mutations still require fresh write proof. Refreshed tokens retain the original deadline. +Logout, expiry or credential replacement before/during a lookup discards its result. No token enters +the application port or HTTP response. + +[Roster queries](../backend/src/domus/application/household_roster.py) require current authority +before source access, release SQLite before provider I/O, then recheck human identity and local +authority before disclosure. A changed membership/policy snapshot requires a fresh view. Alias +updates use their independent current revision. Existing linkage/exception masking is reused; +metadata is declassified to state and name before leaving the application, without HA IDs or +eligibility. Unknown names stay unavailable while canonical local membership remains. Directory +choices require policy management and contain only complete, source-eligible facts. No query +initializes household, membership, profile or preference records. + +## Separate bounded browser recovery + +The [closed command policy](../frontend/src/domain/administration-commands.ts) admits only +`household.update`, `member.update`, `policy.update`, `member.presentation.update` and +`member.preferences.update`. Each payload contains a canonical decimal-text revision. Member +targets occur only on the two member-targeted actions; private preferences have no owner selector. +Ordinary permission decisions are bounded, unique and exclude protected grants. Mapping mode and +target agree. Validation copies and freezes nested values without replacing original text, ordering +or revision intent. Timezone catalog and current authorization remain server responsibilities. + +The pure [administration journal](../frontend/src/domain/administration-journal.ts) owns namespace +`administration`, version 1, at most eight requests in total and a one-hour immutable lifetime. +Each record retains its original installation, release channel, verified subject, action, target, +key, payload, creation/expiry and outcome. Switching contexts hides other contexts' requests while +retaining their unexpired originals. All contexts share the eight-record and total byte budgets; +switching accounts cannot evade the bound. A known operation ID cannot change or become unknown. +Prepared/uncertain records cannot be dismissed as resolved, and expiry never extends their deadline. +The journal neither authenticates a context nor grants backend original-result disclosure. + +An [application port](../frontend/src/application/administration-ports.ts) exposes only the current +context view. Its [session-storage adapter](../frontend/src/adapters/administration-journal.ts) uses +only `domus.administration.journal.v1`, separate from the unchanged entry journal. Canonical JSON +and the 65,536-byte UTF-8 limit are checked before decoding and persistence. Duplicate/noncanonical +encodings, unknown fields, wrong namespaces and numeric revisions fail closed. Compare/readback +fences reject competing or unconfirmed storage writes and retire the in-memory handle. Detach +does not erase pending requests; forgetting a resolved request leaves other contexts and all +unrelated storage intact. Credentials, CSRF, cookies, raw HA facts and response bodies are absent. + +Chromium tests exercise these policies, real isolated browser session storage and provider failure +controls. The application controller consumes the journal through its semantic port. The browser +composition and complete workspace do not yet connect that controller. These tests establish no +automatic operation lookup, mutation replay, HA adoption or full retained-case acceptance. + +The workspace companion now decomposes its eight requirements into 82 prospective acceptance +scenarios with explicit per-area risk budgets and reciprocal requirement links. Its former eight +compound scenario IDs remain as concrete behaviors; the remaining assertions have their own IDs. +The original 80 workspace rows remain, with two independent scope-binding/recovery cases added. +The original 72 administration cases and eight HADI refinements remain unchanged and required. +These inventories are not added together as completed journeys, and 104 journal-related test +executions do not establish the full browser, runtime, migration or manual-review acceptance. + +## Exact HTTP clients and entry compatibility + +The [shared JSON/scalar boundary](../frontend/src/adapters/json-values.ts) validates syntax, +decoded-key uniqueness, depth, original integer lexemes and UTF-8 byte bounds before projecting +facts. Positive aggregate revisions use canonical decimal text throughout browser models; the +unchanged entry setup/enrollment payload keeps its initial literal zero. Timestamps and small +protocol counters require exact safe integers. Entry membership/policy checks, household reads +and original enrollment policy revisions use the same exact representation. Valid backend Unicode +household text is not reinterpreted through JavaScript's different trimming policy. + +The [bounded HTTP transport](../frontend/src/adapters/http-transport.ts) keeps the verified +same-origin application base, context assertions, no-store/content-type checks, redirect refusal, +fatal streaming UTF-8 decoding, cancellation and the existing deadline. Standard responses retain +the 65,536-byte bound. Complete roster/directory reads have a separate 1,048,576-byte bound; tests +exercise 256 members with maximal UTF-8 names/aliases and every supported ordinary permission. +Oversized replies fail without publishing partial rosters. The browser journal limit remains +65,536 bytes in total and is not used as a limit for a complete server roster. + +The [administration client](../frontend/src/adapters/administration-http.ts) exposes only fixed +supported reads and five explicit mutations. Strict projections preserve masked linkage and source +states, separate stored/effective roles, validate response targets and retain exact revisions. +Fresh CSRF precedes each PUT. The original key, payload and expected revision are never rebased or +replaced. Known rejection envelopes expose safe reason/correlation only; lost, malformed or unbound +mutation responses remain uncertain without an automatic resend. + +Protected GET recovery by action/key and by an already captured operation ID keeps its existing +contract, without mutation CSRF or revision parameters. The key route binds returned action/key; +the ID route binds its captured operation ID. Both validate the original action-specific result +and successor revision before returning it. These are processing receipts, not current aggregate +facts; technical event delivery never establishes notification delivery. Non-profile lookup text +retains its original domain label rather than being renamed as a profile change. + +Browser lifecycle consumption, complete EN/ES forms and full administration browser acceptance +remain required. Entry browser compatibility uses +actual Python/SQLite in both deployment fixtures; fixture identities and directly seeded high +revisions do not claim owner HA adoption or a generated history of that many mutations. + +Current wheel and source-image acceptance compare every packaged UI resource with the explicit +`currentSourcePackage` inventory in the administration evidence. The wheel gate first compares +that declaration with the actual local build; the image gate independently compares it with the +installed image and served bytes. Historical entry delivery and installed Edge.5 evidence retain +their original source, resource hashes and adoption claims. A new source build does not inherit +those historical package identities. + +## Administration application controller + +The [controller](../frontend/src/application/administration-controller.ts) uses semantic session, +server, journal, clock and entropy ports. It has no concrete HTTP/storage, framework or DOM import. +The [pure availability policy](../frontend/src/domain/administration-authorization.ts) consumes +current active membership and decoded grants. Stored/effective role labels and provider facts do +not grant an action; the backend independently reauthorizes every operation. + +Refresh reads a verified session, current access and version-2 capabilities, then collects only permitted +facts. Failed reads stay unavailable with a scoped safe reason. Owner preferences must name the +current authorization member. The server-owned household routing identifier in capabilities binds +household/policy facts and receipts independently of current aggregate-read permission. The +identifier does not claim household existence, select a caller or grant access. Legacy unbound +version-1 capabilities and unknown versions fail closed; journal version 1 remains unchanged. +Access is read again after collection; changed grants or membership +discard the collected private view. Replacing the same account's membership also retires any old +private receipt. Read-only outage leases allow permitted local facts until expiry, without enabling +a mutation. Existing owned-journal GET recovery remains available under the same read lease. + +An explicit submission refreshes proof and facts, checks the captured exact aggregate revision, +rejects exhaustion, and persists the original command before one send. An unresolved request blocks +replacement work on the same aggregate while different explicit aggregates remain possible within +the global journal bounds. A known rejection stays rejected; an unbound or lost mutation reply +stays uncertain. No submission is rebased or retried automatically. + +Explicit recovery refreshes proof and original-action authority, checks the unchanged stored +original, and performs only the protected key GET or captured-ID GET. It never calls the mutation +port or adds a current aggregate-read/revision precondition to that existing lookup contract. +Processing receipts and current facts remain separate, including historical results after restore. +A journal failure after a valid result preserves that result in memory on its verified context; +it does not turn a saved effect into a failed mutation or erase the stored original. + +Generations fence canceled reads, preflight, commands and lookup responses. Closing or expiring the +controller hides private facts immediately; late results cannot repopulate them. Journal views hide +currently unauthorized action payloads while retaining underlying unexpired originals. Explicit +dismissal removes only a resolved owned request. Composition still needs to wire the controller to +page lifetime and the complete localized forms; controller/port tests are not those product journeys. + +Valid read-only outage leases also retain the existing local original-result GET contract, including +explicit journal recovery. Both actual deployments return the owned original without changing +database bytes. The controller permits that lookup and local resolved-record dismissal until the +unchanged session deadline, while new submissions remain blocked. Recovering a receipt neither +renews the lease nor replays the mutation. diff --git a/docs/contracts/household-api.openapi.json b/docs/contracts/household-api.openapi.json index 0e8635b..1aa6c6a 100644 --- a/docs/contracts/household-api.openapi.json +++ b/docs/contracts/household-api.openapi.json @@ -93,6 +93,270 @@ ], "type": "string" }, + "AdministrationAction": { + "enum": [ + "household.update", + "member.update", + "policy.update", + "member.presentation.update", + "member.preferences.update" + ], + "type": "string" + }, + "AdministrationCapabilities": { + "additionalProperties": false, + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/AdministrationAction" + }, + "title": "Actions", + "type": "array" + }, + "contract_version": { + "const": 2, + "default": 2, + "title": "Contract Version", + "type": "integer" + }, + "exact_revisions": { + "const": "json-integer-int64", + "default": "json-integer-int64", + "title": "Exact Revisions", + "type": "string" + }, + "household_id": { + "title": "Household Id", + "type": "string" + }, + "journal": { + "$ref": "#/components/schemas/AdministrationJournal" + }, + "state": { + "const": "available", + "default": "available", + "title": "State", + "type": "string" + } + }, + "required": [ + "household_id", + "actions" + ], + "title": "AdministrationCapabilities", + "type": "object" + }, + "AdministrationJournal": { + "additionalProperties": false, + "properties": { + "lifetime_ms": { + "const": 3600000, + "default": 3600000, + "title": "Lifetime Ms", + "type": "integer" + }, + "maximum_bytes": { + "const": 65536, + "default": 65536, + "title": "Maximum Bytes", + "type": "integer" + }, + "maximum_records": { + "const": 8, + "default": 8, + "title": "Maximum Records", + "type": "integer" + }, + "namespace": { + "const": "administration", + "default": "administration", + "title": "Namespace", + "type": "string" + }, + "version": { + "const": 1, + "default": 1, + "title": "Version", + "type": "integer" + } + }, + "title": "AdministrationJournal", + "type": "object" + }, + "AdministrationOperation": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/AdministrationAction" + }, + "action_required": { + "default": "Keep the operation ID. If the response was lost, retry the same payload and key.", + "title": "Action Required", + "type": "string" + }, + "archived_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Archived By" + }, + "command_key": { + "title": "Command Key", + "type": "string" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "event_delivery": { + "enum": [ + "pending", + "delivered", + "suppressed" + ], + "title": "Event Delivery", + "type": "string" + }, + "message": { + "default": "The household change was saved. Event delivery is tracked separately.", + "title": "Message", + "type": "string" + }, + "notification_delivery": { + "const": "unavailable", + "default": "unavailable", + "title": "Notification Delivery", + "type": "string" + }, + "operation_id": { + "title": "Operation Id", + "type": "string" + }, + "result": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Result", + "type": "object" + }, + "state": { + "default": "committed", + "enum": [ + "committed", + "historical" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "operation_id", + "result", + "created_at", + "action", + "command_key", + "event_delivery" + ], + "title": "AdministrationOperation", + "type": "object" + }, + "AdministrationReceipt": { + "additionalProperties": false, + "properties": { + "action_required": { + "default": "Keep the operation ID. If the response was lost, retry the same payload and key.", + "title": "Action Required", + "type": "string" + }, + "archived_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Archived By" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "message": { + "default": "The household change was saved. Event delivery is tracked separately.", + "title": "Message", + "type": "string" + }, + "notification_delivery": { + "const": "unavailable", + "default": "unavailable", + "title": "Notification Delivery", + "type": "string" + }, + "operation_id": { + "title": "Operation Id", + "type": "string" + }, + "result": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Result", + "type": "object" + }, + "state": { + "default": "committed", + "enum": [ + "committed", + "historical" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "operation_id", + "result", + "created_at" + ], + "title": "AdministrationReceipt", + "type": "object" + }, + "AliasRequest": { + "additionalProperties": false, + "properties": { + "alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Alias" + }, + "expected_revision": { + "format": "int64", + "maximum": 9223372036854775807, + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" + } + }, + "required": [ + "expected_revision", + "alias" + ], + "title": "AliasRequest", + "type": "object" + }, "AssignmentBody": { "additionalProperties": false, "properties": { @@ -106,7 +370,8 @@ "type": "array" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -137,7 +402,8 @@ "additionalProperties": false, "properties": { "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -158,7 +424,8 @@ "title": "Material Usages" }, "performed_at": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 0.0, "title": "Performed At", "type": "integer" @@ -267,7 +534,8 @@ "due_at": { "anyOf": [ { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 0.0, "type": "integer" }, @@ -359,7 +627,8 @@ "additionalProperties": false, "properties": { "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -442,7 +711,8 @@ "type": "string" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -598,6 +868,59 @@ "title": "DependencyReviewView", "type": "object" }, + "DirectoryChoiceResponse": { + "additionalProperties": false, + "properties": { + "ha_user_id": { + "title": "Ha User Id", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + } + }, + "required": [ + "ha_user_id", + "name" + ], + "title": "DirectoryChoiceResponse", + "type": "object" + }, + "DirectoryResponse": { + "additionalProperties": false, + "properties": { + "profiles": { + "items": { + "$ref": "#/components/schemas/DirectoryChoiceResponse" + }, + "title": "Profiles", + "type": "array" + }, + "state": { + "enum": [ + "available", + "denied", + "unavailable" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "state", + "profiles" + ], + "title": "DirectoryResponse", + "type": "object" + }, "EmptyRequest": { "additionalProperties": false, "properties": {}, @@ -744,7 +1067,8 @@ "additionalProperties": false, "properties": { "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -1147,7 +1471,8 @@ "type": "string" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -1218,6 +1543,14 @@ "title": "MaterialUsageBody", "type": "object" }, + "MemberLocale": { + "enum": [ + "en", + "es" + ], + "title": "MemberLocale", + "type": "string" + }, "MemberResponse": { "additionalProperties": false, "properties": { @@ -1292,7 +1625,8 @@ "type": "array" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -1333,7 +1667,8 @@ "additionalProperties": false, "properties": { "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -1643,7 +1978,8 @@ "additionalProperties": false, "properties": { "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 0.0, "title": "Expected Revision", "type": "integer" @@ -1702,7 +2038,8 @@ "type": "array" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -1730,24 +2067,135 @@ "title": "PolicyUpdateRequest", "type": "object" }, - "Priority": { - "enum": [ - "low", - "normal", - "high", - "urgent" - ], - "title": "Priority", - "type": "string" - }, - "ProfileDecision": { + "PreferencesRequest": { "additionalProperties": false, "properties": { - "allow": { - "title": "Allow", - "type": "boolean" + "expected_revision": { + "format": "int64", + "maximum": 9223372036854775807, + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" }, - "permission": { + "locale": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemberLocale" + }, + { + "type": "null" + } + ] + }, + "routine_participation_summaries": { + "title": "Routine Participation Summaries", + "type": "boolean" + } + }, + "required": [ + "expected_revision", + "locale", + "routine_participation_summaries" + ], + "title": "PreferencesRequest", + "type": "object" + }, + "PreferencesResponse": { + "additionalProperties": false, + "properties": { + "locale": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemberLocale" + }, + { + "type": "null" + } + ] + }, + "member_id": { + "title": "Member Id", + "type": "string" + }, + "notification_delivery": { + "const": "unavailable", + "default": "unavailable", + "title": "Notification Delivery", + "type": "string" + }, + "revision": { + "format": "int64", + "maximum": 9223372036854775807, + "minimum": 1.0, + "title": "Revision", + "type": "integer" + }, + "routine_participation_summaries": { + "title": "Routine Participation Summaries", + "type": "boolean" + } + }, + "required": [ + "member_id", + "locale", + "routine_participation_summaries", + "revision" + ], + "title": "PreferencesResponse", + "type": "object" + }, + "PresentationResponse": { + "additionalProperties": false, + "properties": { + "alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Alias" + }, + "member_id": { + "title": "Member Id", + "type": "string" + }, + "revision": { + "format": "int64", + "maximum": 9223372036854775807, + "minimum": 1.0, + "title": "Revision", + "type": "integer" + } + }, + "required": [ + "member_id", + "alias", + "revision" + ], + "title": "PresentationResponse", + "type": "object" + }, + "Priority": { + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "title": "Priority", + "type": "string" + }, + "ProfileDecision": { + "additionalProperties": false, + "properties": { + "allow": { + "title": "Allow", + "type": "boolean" + }, + "permission": { "$ref": "#/components/schemas/Permission" }, "role": { @@ -1924,7 +2372,8 @@ "additionalProperties": false, "properties": { "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -1945,7 +2394,8 @@ "type": "string" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -1975,6 +2425,85 @@ "title": "Role", "type": "string" }, + "RosterMemberResponse": { + "additionalProperties": false, + "properties": { + "member": { + "$ref": "#/components/schemas/MemberResponse" + }, + "metadata": { + "$ref": "#/components/schemas/RosterMetadataResponse" + }, + "presentation": { + "$ref": "#/components/schemas/PresentationResponse" + } + }, + "required": [ + "member", + "presentation", + "metadata" + ], + "title": "RosterMemberResponse", + "type": "object" + }, + "RosterMetadataResponse": { + "additionalProperties": false, + "properties": { + "avatar_state": { + "const": "unavailable", + "default": "unavailable", + "title": "Avatar State", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "state": { + "$ref": "#/components/schemas/SourceState" + } + }, + "required": [ + "state", + "name" + ], + "title": "RosterMetadataResponse", + "type": "object" + }, + "RosterResponse": { + "additionalProperties": false, + "properties": { + "directory_state": { + "enum": [ + "available", + "denied", + "unavailable" + ], + "title": "Directory State", + "type": "string" + }, + "members": { + "items": { + "$ref": "#/components/schemas/RosterMemberResponse" + }, + "title": "Members", + "type": "array" + } + }, + "required": [ + "members", + "directory_state" + ], + "title": "RosterResponse", + "type": "object" + }, "RulesBody": { "additionalProperties": false, "properties": { @@ -2129,6 +2658,16 @@ "title": "SetupRequest", "type": "object" }, + "SourceState": { + "enum": [ + "available", + "absent", + "denied", + "unavailable" + ], + "title": "SourceState", + "type": "string" + }, "TaskContent": { "properties": { "description": { @@ -2975,7 +3514,8 @@ "additionalProperties": false, "properties": { "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -3007,7 +3547,8 @@ "type": "string" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -3099,7 +3640,8 @@ "$ref": "#/components/schemas/VerificationState" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -3134,6 +3676,1321 @@ }, "openapi": "3.1.0", "paths": { + "/api/v1/administration/capabilities": { + "get": { + "operationId": "capabilities_api_v1_administration_capabilities_get", + "parameters": [ + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdministrationCapabilities" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Capabilities" + } + }, + "/api/v1/administration/directory": { + "get": { + "operationId": "directory_api_v1_administration_directory_get", + "parameters": [ + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DirectoryResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Directory" + } + }, + "/api/v1/administration/members/{member_id}/presentation": { + "get": { + "operationId": "presentation_api_v1_administration_members__member_id__presentation_get", + "parameters": [ + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "title": "Member Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresentationResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Presentation" + }, + "put": { + "operationId": "update_presentation_api_v1_administration_members__member_id__presentation_put", + "parameters": [ + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "title": "Member Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AliasRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdministrationReceipt" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Update Presentation" + } + }, + "/api/v1/administration/preferences": { + "get": { + "operationId": "preferences_api_v1_administration_preferences_get", + "parameters": [ + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Preferences" + }, + "put": { + "operationId": "update_preferences_api_v1_administration_preferences_put", + "parameters": [ + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdministrationReceipt" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Update Preferences" + } + }, + "/api/v1/administration/requests/{action}/{key}": { + "get": { + "operationId": "original_request_api_v1_administration_requests__action___key__get", + "parameters": [ + { + "in": "path", + "name": "action", + "required": true, + "schema": { + "enum": [ + "household.update", + "member.update", + "policy.update", + "member.presentation.update", + "member.preferences.update" + ], + "title": "Action", + "type": "string" + } + }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "title": "Key", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdministrationOperation" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Original Request" + } + }, + "/api/v1/administration/roster": { + "get": { + "operationId": "roster_api_v1_administration_roster_get", + "parameters": [ + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RosterResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Roster" + } + }, "/api/v1/auth/callback": { "get": { "description": "Exactly one of code or error is required, together with state and the bound browser cookie. Providing both outcomes or neither returns 422 without consuming the attempt. A code completes authorization; an error records a declined authorization.", diff --git a/docs/contracts/native-household-api.openapi.json b/docs/contracts/native-household-api.openapi.json index d94a581..4e893f6 100644 --- a/docs/contracts/native-household-api.openapi.json +++ b/docs/contracts/native-household-api.openapi.json @@ -93,6 +93,270 @@ ], "type": "string" }, + "AdministrationAction": { + "enum": [ + "household.update", + "member.update", + "policy.update", + "member.presentation.update", + "member.preferences.update" + ], + "type": "string" + }, + "AdministrationCapabilities": { + "additionalProperties": false, + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/AdministrationAction" + }, + "title": "Actions", + "type": "array" + }, + "contract_version": { + "const": 2, + "default": 2, + "title": "Contract Version", + "type": "integer" + }, + "exact_revisions": { + "const": "json-integer-int64", + "default": "json-integer-int64", + "title": "Exact Revisions", + "type": "string" + }, + "household_id": { + "title": "Household Id", + "type": "string" + }, + "journal": { + "$ref": "#/components/schemas/AdministrationJournal" + }, + "state": { + "const": "available", + "default": "available", + "title": "State", + "type": "string" + } + }, + "required": [ + "household_id", + "actions" + ], + "title": "AdministrationCapabilities", + "type": "object" + }, + "AdministrationJournal": { + "additionalProperties": false, + "properties": { + "lifetime_ms": { + "const": 3600000, + "default": 3600000, + "title": "Lifetime Ms", + "type": "integer" + }, + "maximum_bytes": { + "const": 65536, + "default": 65536, + "title": "Maximum Bytes", + "type": "integer" + }, + "maximum_records": { + "const": 8, + "default": 8, + "title": "Maximum Records", + "type": "integer" + }, + "namespace": { + "const": "administration", + "default": "administration", + "title": "Namespace", + "type": "string" + }, + "version": { + "const": 1, + "default": 1, + "title": "Version", + "type": "integer" + } + }, + "title": "AdministrationJournal", + "type": "object" + }, + "AdministrationOperation": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/AdministrationAction" + }, + "action_required": { + "default": "Keep the operation ID. If the response was lost, retry the same payload and key.", + "title": "Action Required", + "type": "string" + }, + "archived_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Archived By" + }, + "command_key": { + "title": "Command Key", + "type": "string" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "event_delivery": { + "enum": [ + "pending", + "delivered", + "suppressed" + ], + "title": "Event Delivery", + "type": "string" + }, + "message": { + "default": "The household change was saved. Event delivery is tracked separately.", + "title": "Message", + "type": "string" + }, + "notification_delivery": { + "const": "unavailable", + "default": "unavailable", + "title": "Notification Delivery", + "type": "string" + }, + "operation_id": { + "title": "Operation Id", + "type": "string" + }, + "result": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Result", + "type": "object" + }, + "state": { + "default": "committed", + "enum": [ + "committed", + "historical" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "operation_id", + "result", + "created_at", + "action", + "command_key", + "event_delivery" + ], + "title": "AdministrationOperation", + "type": "object" + }, + "AdministrationReceipt": { + "additionalProperties": false, + "properties": { + "action_required": { + "default": "Keep the operation ID. If the response was lost, retry the same payload and key.", + "title": "Action Required", + "type": "string" + }, + "archived_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Archived By" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "message": { + "default": "The household change was saved. Event delivery is tracked separately.", + "title": "Message", + "type": "string" + }, + "notification_delivery": { + "const": "unavailable", + "default": "unavailable", + "title": "Notification Delivery", + "type": "string" + }, + "operation_id": { + "title": "Operation Id", + "type": "string" + }, + "result": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Result", + "type": "object" + }, + "state": { + "default": "committed", + "enum": [ + "committed", + "historical" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "operation_id", + "result", + "created_at" + ], + "title": "AdministrationReceipt", + "type": "object" + }, + "AliasRequest": { + "additionalProperties": false, + "properties": { + "alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Alias" + }, + "expected_revision": { + "format": "int64", + "maximum": 9223372036854775807, + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" + } + }, + "required": [ + "expected_revision", + "alias" + ], + "title": "AliasRequest", + "type": "object" + }, "AssignmentBody": { "additionalProperties": false, "properties": { @@ -106,7 +370,8 @@ "type": "array" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -137,7 +402,8 @@ "additionalProperties": false, "properties": { "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -158,7 +424,8 @@ "title": "Material Usages" }, "performed_at": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 0.0, "title": "Performed At", "type": "integer" @@ -267,7 +534,8 @@ "due_at": { "anyOf": [ { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 0.0, "type": "integer" }, @@ -359,7 +627,8 @@ "additionalProperties": false, "properties": { "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -442,7 +711,8 @@ "type": "string" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -598,6 +868,59 @@ "title": "DependencyReviewView", "type": "object" }, + "DirectoryChoiceResponse": { + "additionalProperties": false, + "properties": { + "ha_user_id": { + "title": "Ha User Id", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + } + }, + "required": [ + "ha_user_id", + "name" + ], + "title": "DirectoryChoiceResponse", + "type": "object" + }, + "DirectoryResponse": { + "additionalProperties": false, + "properties": { + "profiles": { + "items": { + "$ref": "#/components/schemas/DirectoryChoiceResponse" + }, + "title": "Profiles", + "type": "array" + }, + "state": { + "enum": [ + "available", + "denied", + "unavailable" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "state", + "profiles" + ], + "title": "DirectoryResponse", + "type": "object" + }, "EnrollmentRequest": { "additionalProperties": false, "properties": { @@ -738,7 +1061,8 @@ "additionalProperties": false, "properties": { "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -1103,7 +1427,8 @@ "type": "string" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -1174,6 +1499,14 @@ "title": "MaterialUsageBody", "type": "object" }, + "MemberLocale": { + "enum": [ + "en", + "es" + ], + "title": "MemberLocale", + "type": "string" + }, "MemberResponse": { "additionalProperties": false, "properties": { @@ -1248,7 +1581,8 @@ "type": "array" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -1354,7 +1688,8 @@ "additionalProperties": false, "properties": { "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -1664,7 +1999,8 @@ "additionalProperties": false, "properties": { "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 0.0, "title": "Expected Revision", "type": "integer" @@ -1723,7 +2059,8 @@ "type": "array" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -1751,24 +2088,135 @@ "title": "PolicyUpdateRequest", "type": "object" }, - "Priority": { - "enum": [ - "low", - "normal", - "high", - "urgent" - ], - "title": "Priority", - "type": "string" - }, - "ProfileDecision": { + "PreferencesRequest": { "additionalProperties": false, "properties": { - "allow": { - "title": "Allow", - "type": "boolean" + "expected_revision": { + "format": "int64", + "maximum": 9223372036854775807, + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" }, - "permission": { + "locale": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemberLocale" + }, + { + "type": "null" + } + ] + }, + "routine_participation_summaries": { + "title": "Routine Participation Summaries", + "type": "boolean" + } + }, + "required": [ + "expected_revision", + "locale", + "routine_participation_summaries" + ], + "title": "PreferencesRequest", + "type": "object" + }, + "PreferencesResponse": { + "additionalProperties": false, + "properties": { + "locale": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemberLocale" + }, + { + "type": "null" + } + ] + }, + "member_id": { + "title": "Member Id", + "type": "string" + }, + "notification_delivery": { + "const": "unavailable", + "default": "unavailable", + "title": "Notification Delivery", + "type": "string" + }, + "revision": { + "format": "int64", + "maximum": 9223372036854775807, + "minimum": 1.0, + "title": "Revision", + "type": "integer" + }, + "routine_participation_summaries": { + "title": "Routine Participation Summaries", + "type": "boolean" + } + }, + "required": [ + "member_id", + "locale", + "routine_participation_summaries", + "revision" + ], + "title": "PreferencesResponse", + "type": "object" + }, + "PresentationResponse": { + "additionalProperties": false, + "properties": { + "alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Alias" + }, + "member_id": { + "title": "Member Id", + "type": "string" + }, + "revision": { + "format": "int64", + "maximum": 9223372036854775807, + "minimum": 1.0, + "title": "Revision", + "type": "integer" + } + }, + "required": [ + "member_id", + "alias", + "revision" + ], + "title": "PresentationResponse", + "type": "object" + }, + "Priority": { + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "title": "Priority", + "type": "string" + }, + "ProfileDecision": { + "additionalProperties": false, + "properties": { + "allow": { + "title": "Allow", + "type": "boolean" + }, + "permission": { "$ref": "#/components/schemas/Permission" }, "role": { @@ -1945,7 +2393,8 @@ "additionalProperties": false, "properties": { "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -1966,7 +2415,8 @@ "type": "string" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -1996,6 +2446,85 @@ "title": "Role", "type": "string" }, + "RosterMemberResponse": { + "additionalProperties": false, + "properties": { + "member": { + "$ref": "#/components/schemas/MemberResponse" + }, + "metadata": { + "$ref": "#/components/schemas/RosterMetadataResponse" + }, + "presentation": { + "$ref": "#/components/schemas/PresentationResponse" + } + }, + "required": [ + "member", + "presentation", + "metadata" + ], + "title": "RosterMemberResponse", + "type": "object" + }, + "RosterMetadataResponse": { + "additionalProperties": false, + "properties": { + "avatar_state": { + "const": "unavailable", + "default": "unavailable", + "title": "Avatar State", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "state": { + "$ref": "#/components/schemas/SourceState" + } + }, + "required": [ + "state", + "name" + ], + "title": "RosterMetadataResponse", + "type": "object" + }, + "RosterResponse": { + "additionalProperties": false, + "properties": { + "directory_state": { + "enum": [ + "available", + "denied", + "unavailable" + ], + "title": "Directory State", + "type": "string" + }, + "members": { + "items": { + "$ref": "#/components/schemas/RosterMemberResponse" + }, + "title": "Members", + "type": "array" + } + }, + "required": [ + "members", + "directory_state" + ], + "title": "RosterResponse", + "type": "object" + }, "RulesBody": { "additionalProperties": false, "properties": { @@ -2091,6 +2620,16 @@ "title": "SetupRequest", "type": "object" }, + "SourceState": { + "enum": [ + "available", + "absent", + "denied", + "unavailable" + ], + "title": "SourceState", + "type": "string" + }, "TaskContent": { "properties": { "description": { @@ -2937,7 +3476,8 @@ "additionalProperties": false, "properties": { "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -2969,7 +3509,8 @@ "type": "string" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -3061,7 +3602,8 @@ "$ref": "#/components/schemas/VerificationState" }, "expected_revision": { - "maximum": 9.223372036854776e+18, + "format": "int64", + "maximum": 9223372036854775807, "minimum": 1.0, "title": "Expected Revision", "type": "integer" @@ -3096,6 +3638,1321 @@ }, "openapi": "3.1.0", "paths": { + "/api/v1/administration/capabilities": { + "get": { + "operationId": "capabilities_api_v1_administration_capabilities_get", + "parameters": [ + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdministrationCapabilities" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Capabilities" + } + }, + "/api/v1/administration/directory": { + "get": { + "operationId": "directory_api_v1_administration_directory_get", + "parameters": [ + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DirectoryResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Directory" + } + }, + "/api/v1/administration/members/{member_id}/presentation": { + "get": { + "operationId": "presentation_api_v1_administration_members__member_id__presentation_get", + "parameters": [ + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "title": "Member Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresentationResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Presentation" + }, + "put": { + "operationId": "update_presentation_api_v1_administration_members__member_id__presentation_put", + "parameters": [ + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "title": "Member Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AliasRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdministrationReceipt" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Update Presentation" + } + }, + "/api/v1/administration/preferences": { + "get": { + "operationId": "preferences_api_v1_administration_preferences_get", + "parameters": [ + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Preferences" + }, + "put": { + "operationId": "update_preferences_api_v1_administration_preferences_put", + "parameters": [ + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Origin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-CSRF-Token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdministrationReceipt" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Update Preferences" + } + }, + "/api/v1/administration/requests/{action}/{key}": { + "get": { + "operationId": "original_request_api_v1_administration_requests__action___key__get", + "parameters": [ + { + "in": "path", + "name": "action", + "required": true, + "schema": { + "enum": [ + "household.update", + "member.update", + "policy.update", + "member.presentation.update", + "member.preferences.update" + ], + "title": "Action", + "type": "string" + } + }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "title": "Key", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdministrationOperation" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Original Request" + } + }, + "/api/v1/administration/roster": { + "get": { + "operationId": "roster_api_v1_administration_roster_get", + "parameters": [ + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RosterResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Roster" + } + }, "/api/v1/auth/csrf": { "get": { "operationId": "csrf_api_v1_auth_csrf_get", diff --git a/docs/evidence/household-administration.json b/docs/evidence/household-administration.json index 17d014a..03141a7 100644 --- a/docs/evidence/household-administration.json +++ b/docs/evidence/household-administration.json @@ -70,7 +70,7 @@ "consumer": "Original failing and passing model/backend measurements, source inventory and pinned SDD reproduction retained for current PR review; disposable coverage databases, XML and pytest cache removed.", "privateLocalOnly": true }, - "pending": "Owner-private application/HTTP original results and audit privacy, verified HA metadata, separate journal, full EN/ES administration workspace, complete72-case product acceptance and fresh-head image/native checks after documentation correction. Recurrence continuation remains issue23; installed Edge.5 unchanged.", + "pending": "Browser runtime/lifecycle consumption, all EN/ES administration forms and Storybook/real Python-SQLite administration journeys, safe avatar-source feasibility/owner evidence when available, and complete retained HADM72/HADI8/HADW82 acceptance. Original80workspace rows remain unchanged with2 additional verification cases. Recurrence23, installed Edge.5, Stable/all-domain release and full goal remain incomplete.", "acceptance": "138 parameterized unit executions and a complete backend campaign are not 72 completed administration journeys. No notification delivery, HA metadata availability, schema migration, package/adoption, all-domain release or Stable distribution acceptance is claimed.", "storageIncrement": { "issue": "https://github.com/vypdev/domus/issues/21", @@ -173,5 +173,683 @@ "report": "https://github.com/vypdev/copilot/issues/859", "pending": "Fresh native reconciliation after documentation correction; no clean-review claim inferred from an empty or manually resolved thread." } + }, + "applicationIncrement": { + "issue": "https://github.com/vypdev/domus/issues/25", + "sourceBase": "171244421fcc6289180bdb94bf4e7bb52a990a1b", + "baselineMerge": "6d84d4815a8bbfe071979df6650d01e433dcc2db", + "implemented": "Currently authorized independent alias/private-preference commands and queries, owner-only original-result lookup/replay, safe shared audit/events, atomic CAS and both-deployment protected profile HTTP. Read-only request-key recovery is closed to five administration actions and does not resend mutations.", + "sourceIdentity": { + "authoredModules": 146, + "backendInventorySHA256": "bd91747546304d1e8502abe9111c3432c3e0bf7566c3c3ef76b20b716c34f5a3", + "wheelSHA256": "c0a4658b7001e3438407e3a108bc09f93b178cd96dc8d572f0d6afd71c950ade", + "everyModuleByteVerified": true + }, + "testsFirst": [ + "Initial authored application test could not import the absent queries module.", + "Original-key recovery test first raised AttributeError before the application method existed.", + "First profile HTTP test returned404 before the route was wired.", + "Expanded invalid-alias tests found ten500 responses in both modes before safe public error classification was added. No state was committed." + ], + "completeBackend": { + "executions": 4238, + "authoredModules": 146, + "statements": 10174, + "branches": 2864, + "coveragePercent": 100, + "independentGlobalAndPerModuleAudit": "passed" + }, + "focused": { + "executions": 162, + "newModules": 6, + "statements": 222, + "branches": 34, + "coveragePercent": 100, + "scope": "Application/HTTP, legacy original queries and transport factory controls. The two additional actual production composition executions are included in the4238 full campaign; no product-journey count is inferred." + }, + "checks": { + "strictTypes": "passed146modules", + "ruff": "passed364files", + "architecture": "3kept0broken;190files1252dependencies", + "documentationLinks": "passed", + "sdd": { + "originalPolicyModulesByteVerified": 7, + "format": "PASS", + "consistency": "PASS", + "owningDocuments": 19, + "retainedAdministrationCases": 72, + "retainedIntegrationRefinements": 8 + } + }, + "runtimeControls": { + "source": "tests/containers/test_administration_image.py", + "scope": "Both exact fresh/schema8 owned-runtime storage scripts and both protected ASGI profile API scripts execute on host with fake HA transports. Actual current-source CPU images remain hosted CI work. No owner HA acceptance is claimed.", + "notificationDelivery": "unavailable regardless of technical event acknowledgement" + }, + "copilotReports": { + "unsupportedCommand": "https://github.com/vypdev/copilot/issues/858#issuecomment-6581571932", + "selectedSource": "https://github.com/vypdev/copilot/issues/836#issuecomment-6581572306", + "circularRecovery": "https://github.com/vypdev/copilot/issues/860#issuecomment-6581572803", + "terminalMapping": "https://github.com/vypdev/copilot/issues/833#issuecomment-6581573338", + "recovery": "One terminal-job retry41466725960/job125382987505 succeeded, restored branched/state:working and retained exact1712444 publication. This is recovery, not proof of an upstream fix." + }, + "localEvidence": { + "root": "/private/tmp/domus-admin-api-schema9", + "privateLocalOnly": true, + "scope": "Original failures, full/focused coverage, source/wheel proof and exact container host controls retained for review. Disposable terminal test DBs are retired after necessary evidence is archived." + }, + "pending": "Verified HA metadata/directory, separate exact-revision administration browser journal, complete EN/ES roster/settings/profile/mapping workspace, Storybook and real Python/SQLite browser journeys, complete72-case product acceptance and current-head hosted image/native review. Recurrence remains issue23; installed Edge.5 unchanged.", + "acceptance": "The six-module application/API increment does not complete the retained72 administration journeys, UI/journal/provider metadata, HA adoption, Stable release or complete product." + }, + "precisionIncrement": { + "sourceBase": "b14c308a28ecad52945a8edac6dce25ba9dfd655", + "implemented": "Exact explicitly declared int64 OpenAPI bounds in both deployments; pure browser administration values and source-preserving bounded JSON codec. HTTP/controller/journal/workspace consumption remains outstanding.", + "validFinding": { + "comment": "https://github.com/vypdev/domus/pull/26#discussion_r4444683269", + "originalControl": "The unchanged byte-verified b14c308 wheel fails both new maximum-schema regressions. The plain FastAPI negative control converts exact Pydantic bounds to float; the DomusAPI subclass restores only declared full int64 bounds and preserves smaller/unmarked/approximate bounds.", + "initialHarnessError": "An earlier scratch invocation used a missing basetemp parent and did not execute the property. This is not described as a product failure.", + "status": "Corrected in working source; fresh native reconciliation and hosted checks remain required." + }, + "falsePositive": { + "comment": "https://github.com/vypdev/domus/pull/26#discussion_r4444683256", + "report": "https://github.com/vypdev/copilot/issues/861", + "target": "docs/household-administration-migration.md", + "originalBlob": "814ef09ab5cdc453d66763a6ffe9adcb3082e645", + "sha256": "4bb1ebf920bd133c7fe8d89f897e3a037cf83d6dc37cedf07bd66e9508ec020d", + "proof": "Exact reviewed-head Git and connected GitHub file reads return the unchanged tracked guide; actual local/hosted documentation checks pass. Correct link/guide retained; evidence reply4444764034 and thread resolution recorded. Distinct from the valid OpenAPI correction." + }, + "backend": { + "executions": 4242, + "authoredModules": 147, + "statements": 10186, + "branches": 2874, + "coveragePercent": 100, + "independentGlobalAndPerModuleAudit": "passed" + }, + "frontend": { + "executions": 661, + "newValueAndCodecExecutions": 84, + "authoredRuntimeFiles": 17, + "statements": 1161, + "branches": 841, + "functions": 225, + "lines": 792, + "allFourMetricsPercent": 100, + "independentFilesystemInventoryAudit": "passed", + "scope": "Actual Chromium unit/browser execution, including all existing application tests. New helpers are not yet wired to the full administration UI." + }, + "sourceIdentity": { + "authoredModules": 147, + "backendInventorySHA256": "41f71b09c5699246ba25d39a1791bd8981d6c99d406ccbcd10525068f9cd4fa6", + "wheelSHA256": "2f50c10b5359200eb2d818ace3e6f676cd24bc302bd0ae7ce73323e930d875d1", + "everyModuleByteVerified": true + }, + "checks": { + "strictBackendTypes": "passed147modules", + "strictFrontendTypes": "passed", + "ruff": "passed366files", + "architecture": "3kept0broken;191files1256dependencies", + "originalSddPolicyModulesByteVerified": 7, + "originalSddFormatConsistencyAndCatalog": "passed" + }, + "previousHostedHead": { + "head": "b14c308a28ecad52945a8edac6dce25ba9dfd655", + "ci": 41477642024, + "aggregate": 125391344047, + "amd64": 125387329340, + "aarch64": 125387329273, + "productResult": "success", + "native": 41477642019, + "nativeJob": 125387329358, + "partitions": "6/6", + "context": "complete", + "publishedFindings": 2, + "disposition": "One valid maximum correction and one proved missing-guide false positive. This is not a zero-finding current-head claim." + }, + "localEvidence": { + "root": "/private/tmp/domus-admin-int64-b14", + "privateLocalOnly": true, + "scope": "Exact original-wheel failure, original framework control, complete backend/frontend coverage, source/wheel and original SDK proof; scratch DBs retired after producers end." + }, + "pending": "Wire exact administration/entry revision decoding, HA metadata and directory facts, separate bounded administration journal, full EN/ES workspace, Storybook and real Python/SQLite browser journeys, all72 owner journeys and fresh hosted image/native acceptance. No owner HA update, Stable release, recurrence23 or full product completion." + }, + "sourceIncrement": { + "sourceBase": "2b6e7e18ac246b62aa70cfcee69d0c8aaed34231", + "implemented": "Bounded literal HA name/eligibility source facts, distinct available/absent/denied/unavailable states, exact own OAuth fallback, native verified-subject binding, private credential/lease fences and currently authorized masked roster/directory HTTP in both deployments. No source role becomes a household grant.", + "sourceIdentity": { + "authoredModules": 151, + "backendInventorySHA256": "51290ec09e62eeb5a2d1f963554bd913bc798447860b44357cf98715b1e7f30a", + "wheelSHA256": "2e02ac4f0c35613e618c089c51accef159661f2e058a5a6eb301721c18bb27e0", + "everyModuleByteVerified": true + }, + "backend": { + "executions": 4354, + "authoredModules": 151, + "statements": 10432, + "branches": 2928, + "coveragePercent": 100, + "independentGlobalAndPerModuleAudit": "passed" + }, + "testsFirst": [ + "Domain/query/HA adapter imports failed before the new modules existed.", + "Nine original session cases failed on absent metadata access before session integration.", + "Both actual composition roster tests initially returned404 before the routes were wired.", + "A prototype application projection retained provider linkage inside nested metadata; the new declassification test failed before state/name-only projection was implemented and before HTTP publication.", + "A known account with no name initially claimed available metadata; the new test required an unavailable label while retaining a complete directory." + ], + "controls": [ + "Both actual deployments retain current proof, unchanged lease deadlines, fresh-grant rotation and logout/expiry/subject-change fences.", + "Provider reads occur outside SQLite; actual concurrent alias changes are visible, while changed membership/policy or lost authority blocks disclosure.", + "Both-mode wire tests preserve existing linkage/exception masking and omit source IDs/eligibility/private preferences from ordinary metadata.", + "Real loopback HA fixtures cover complete/empty/omitted/denied/unavailable/malformed/overflow data and literal text; technical native tokens never supply the own-user OAuth fallback.", + "Current Domus administration selected for a non-HA-administrator does not claim HA directory permission: standalone returns denied facts; native uses its separate authorized technical data lookup.", + "All six exact source image scripts pass on the host, including the new roster/directory controls; current-head actual CPU acceptance remains hosted CI work." + ], + "checks": { + "strictBackendTypes": "passed151modules", + "ruff": "passed374files", + "architecture": "3kept0broken;195files1299dependencies", + "documentationLinks": "passed", + "originalSdkPolicyModulesByteVerified": 7, + "originalSddFormatConsistencyAndCatalog": "passed" + }, + "primaryProviderContracts": [ + "https://github.com/home-assistant/core/blob/2026.10.4/homeassistant/components/auth/__init__.py#L513", + "https://github.com/home-assistant/core/blob/2026.10.4/homeassistant/components/config/auth.py#L19" + ], + "previousHostedHead": { + "head": "2b6e7e18ac246b62aa70cfcee69d0c8aaed34231", + "ci": 41481867481, + "aggregate": 125405907460, + "backend": 125402001334, + "amd64": 125402002305, + "aarch64": 125402001582, + "productResult": "success", + "native": 41481867204, + "nativeJob": 125401999096, + "partitions": "6/6", + "context": "complete", + "open": 0, + "fixed": 1, + "dismissed": 1, + "scope": "One valid maximum correction and one proved missing-guide false positive; source work requires fresh hosted review." + }, + "localEvidence": { + "root": "/private/tmp/domus-member-source-2b6e7e1", + "privateLocalOnly": true, + "scope": "Original failures, full/focused backend coverage, source/wheel, source/provider/session/projection and exact image-script controls; disposable DBs retired after producers end." + }, + "pending": "Complete frontend HTTP/controller consumption, exact entry/admin revision integration, all EN/ES forms and Storybook/real Python-SQLite journeys, safe avatar-source feasibility and owner evidence when available, and all 72 retained owner-case acceptance. The separate journal policy/storage is now tested; workspace integration remains. Recurrence23, installed Edge.5, Stable/all-domain release and the full goal remain incomplete." + }, + "browserJournalIncrement": { + "sourceBase": "db26dde72985b57ad1377be83c554e449a5c4f8e", + "goalComplete": false, + "sourceInventorySHA256": "8468b1738ec5d49c7d4391b5953d41e8222222e20aebf239f8ca92696102fcc6", + "authoredFrontendFiles": 21, + "frontend": { + "executions": 765, + "newJournalExecutions": 104, + "runtimeFiles": 21, + "coverage": { + "lines": { + "total": 931, + "covered": 931, + "skipped": 0, + "pct": 100 + }, + "statements": { + "total": 1345, + "covered": 1345, + "skipped": 0, + "pct": 100 + }, + "functions": { + "total": 274, + "covered": 274, + "skipped": 0, + "pct": 100 + }, + "branches": { + "total": 969, + "covered": 969, + "skipped": 0, + "pct": 100 + } + }, + "independentFilesystemAudit": "passed" + }, + "architecture": { + "nodePolicyExecutions": 38, + "sourceGraphAndNegativeFixtures": "passed" + }, + "types": "runtime and Storybook/catalog pass", + "sdk": "7 original policy module blobs unchanged; format/consistency/catalog pass; 19 owner documents and 72 original administration cases retained", + "testsFirst": [ + "Initial import failed before the command/journal modules existed.", + "The adapter suite failed before the administration storage adapter existed.", + "Unicode NEL-only household name was accepted by the prototype JS trim policy; the new backend-policy regression failed before White_Space validation.", + "The exact original-intent encoder test failed before the export existed." + ], + "scope": "Pure closed commands, separate context-filtered globally bounded administration journal, session-storage CAS/readback and exact original-intent encoder. Existing entry journal schema/behavior and source backend are unchanged. No full HTTP/controller/workspace integration, real owner HA acceptance or all72 journey acceptance.", + "commands": [ + "pnpm test:frontend", + "pnpm check:frontend", + "pnpm test", + "domus-check docs", + "node /private/tmp/domus-admin-api-schema9/validate-native.cjs" + ], + "localEvidence": { + "root": "/private/tmp/domus-admin-journal-db26dde", + "privateLocalOnly": true + }, + "currentPublishedSourceProof": "/private/tmp/domus-member-source-2b6e7e1/hosted-current.json" + }, + "acceptanceBudgetReview": { + "previousSource": "59c134a6585a3866f9d61143fb2d2b3abda12fb6", + "finding": "specs/household-administration-workspace.md:test-budget-understates-new-scope", + "disposition": "Valid; corrected in c357ce4920ee54ca01a14b3a11774587fe885159. Native run41496499460/job125452728775 confirms fixed with complete9/9partitions and zero open/reopened/verification-required findings. Later source needs fresh review.", + "workspaceCaseCount": 80, + "requirementsUnchanged": 8, + "retainedOriginalAdministrationCases": 72, + "retainedHadiRefinements": 8, + "counts": { + "Domain/configuration/pure planning": 9, + "State/application/idempotency/races": 14, + "Adapters/provider contracts": 11, + "Application use cases": 9, + "Security/abuse": 13, + "Presentation/localization/accessibility/sanitization": 10, + "Integration/end-to-end/compatibility/migration": 9, + "Workflows/permissions/schemas/setup": 5 + }, + "scope": "Prospective acceptance inventory only; no new product policy, reduced owner scope, executed-case or complete-workspace claim.", + "sdk": "Original pinned SDK format/consistency validator passes all80 inventory rows, per-area allocations and reciprocal requirement links. Seven original policy modules remain byte-verified.", + "productAndOwnerScope": "The same eight HADW requirements are decomposed; original HADM72, HADI8 and all other owners remain unchanged. Counts are proposed scenarios, not executed journeys; runtime source and its complete coverage are unchanged by this document correction." + }, + "httpClientIncrement": { + "sourceBase": "c357ce4920ee54ca01a14b3a11774587fe885159", + "recordedAtUTC": "2026-11-02T18:59:02.507140+00:00", + "goalComplete": false, + "implemented": "Shared bounded protected transport and original-source integer decoding; exact revisions throughout existing entry reads and receipts; fixed administration reads, five PUT actions and explicit original-key/captured-operation-ID GET recovery. Closed server-fact projections retain masked fields, private preferences and honest HA source states. Household recovery keeps its domain label.", + "sourceIdentity": { + "algorithm": "SHA256 of canonical sorted JSON mapping of repository-relative frontend runtime paths to SHA256 bytes; includes type-only files", + "frontendInventorySHA256": "ccf7c7c89181d0a2cf638a4c92f142f81c41055f79a45e4be68976636828f990", + "backendInventorySHA256": "8497016eba6245453ac21f34a5e0bd7865a4afd6c0645be99889446ef53687b1", + "wheelSHA256": "cbf076c8963b412ae211d00db5451f12305ce6fb8aa06b0d975e72f1f55561ec", + "everyBackendModuleAndUiByteVerifiedAgainstWheel": true + }, + "testsFirst": [ + "Original entry client failed18 new exact-int64 executions before integration; ordinary controls passed.", + "Administration suite failed on the absent client module; the existing operation-ID recovery contract separately failed five executions before its method was implemented.", + "Household-key recovery message regression first failed2of4 both-deployment cases; profile controls passed. The message is now action-specific.", + "The earlier retained generated UI failed both actual Python/SQLite high-revision journeys after successful max-int64 fixture setup. Its exact bytes are archived; its source commit is not inferred.", + "An earlier browser attempt failed at an ambiguous heading selector before the property ran. A later prototype import error blocked backend collection/server startup. These harness/wiring failures are retained separately and do not establish precision regressions.", + "A prototype maximal-roster fixture reused HA IDs; corrected to unique IDs while preserving the production duplicate guard. The complete large-reply control then passed.", + "Backend-valid FEFF household text initially failed the browser projection; the compatibility regression now passes." + ], + "backend": { + "executions": 4358, + "authoredModules": 151, + "statements": 10433, + "branches": 2928, + "coveragePercent": 100, + "independentGlobalAndPerModuleAudit": "passed" + }, + "frontend": { + "executions": 897, + "authoredRuntimeFiles": 26, + "nonemptyRuntimeFiles": 24, + "typeOnlyFiles": 2, + "coverage": { + "lines": { + "total": 1067, + "covered": 1067, + "skipped": 0, + "pct": 100 + }, + "statements": { + "total": 1531, + "covered": 1531, + "skipped": 0, + "pct": 100 + }, + "functions": { + "total": 319, + "covered": 319, + "skipped": 0, + "pct": 100 + }, + "branches": { + "total": 1084, + "covered": 1084, + "skipped": 0, + "pct": 100 + } + }, + "independentFilesystemInventoryAudit": "passed" + }, + "browser": { + "actualEntryJourneys": { + "startTime": "2026-11-02T18:46:00.410Z", + "duration": 30494.852000000003, + "expected": 51, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "actualCatalogJourneys": { + "startTime": "2026-11-02T18:50:36.761Z", + "duration": 9008.242999999999, + "expected": 18, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "scope": "Compiled packaged UI with real Python/SQLite in native and standalone fixtures; two new max-int64 journeys directly seed disposable existing records. Not billions of actual mutations, migration lineage or owner HA adoption." + }, + "controls": [ + "Complete256-member roster with maximal UTF-8 names/aliases and all16 ordinary decisions exceeds the standard65,536-byte bound and remains below the fixed1,048,576-byte roster/directory bound; no truncation.", + "Fresh CSRF before PUT, same-origin/no-store/fatal UTF-8/timeout/cancel and context guards retained; read-only recovery adds no mutation precondition. Deadline continuation cannot dispatch a PUT after abort.", + "Original payload/key/revision and known operation ID remain bound. Lost/malformed/unbound PUT stays uncertain; no automatic mutation retry or rebase.", + "Action-specific receipts retain exact successor revisions; technical event processing does not claim notification delivery. Controller must still bind own preferences to current member before private presentation." + ], + "checks": { + "frontendRuntimeAndCatalogTypes": "passed", + "nodeArchitectureExecutions": 38, + "nodePolicyCoverage": "100%", + "backendTypes": "passed151modules", + "ruff": "passed374files", + "pythonArchitecture": "3kept0broken;195files1300dependencies", + "documentationLinks": "passed", + "packagingExecutions": 1, + "packagingScope": "Actual wheel matches all source/UI bytes and excludes browser-only fixture controls.", + "sdk": "All7 original modules byte-verified; format/consistency/catalog pass;19owners,HADM72,HADI8,HADW80 retained." + }, + "previousPublishedHead": { + "head": "c357ce4920ee54ca01a14b3a11774587fe885159", + "ci": 41496499475, + "aggregate": 125456640681, + "amd64": 125452728042, + "aarch64": 125452727844, + "productResult": "success", + "native": 41496499460, + "nativeJob": 125452728775, + "partitions": "9/9", + "context": "complete", + "open": 0, + "fixed": 2, + "dismissed": 1, + "scope": "Current published checkpoint only. The local HTTP increment requires fresh hosted images and native review after push.", + "progressFailure": "https://github.com/vypdev/copilot/issues/828#issuecomment-6583615211" + }, + "localEvidence": { + "root": "/private/tmp/domus-admin-http-c357ce4", + "privateLocalOnly": true, + "scope": "Original failures, exact earlier UI input, both real negative-control captures, complete backend/frontend measurements, final browser results, source/wheel and unchanged SDK proofs. Disposable terminal DB/build outputs retire after compact byte-verified evidence is retained.", + "archive": { + "path": "/private/tmp/domus-admin-http-c357ce4/verification-evidence.tar.gz", + "sha256": "1c6262f84aaeddb2065273dae42f8e9a056cd9935c683ef2222eab0448fe9533", + "members": 62, + "allMembersByteVerified": true + } + }, + "pending": "AdministrationController/lifecycle integration, own-member private projection binding, full EN/ES roster/settings/member/mapping/alias/preferences forms, Storybook and actual administration Python/SQLite journeys, avatar-source feasibility and full retained owner/workspace acceptance. Recurrence23, Stable/all-domain release and full goal remain incomplete.", + "hostedNative": { + "head": "7be2810092021aa044f0c476d429f689b9113d61", + "run": 41505801599, + "job": 125484708663, + "partitions": "11/11", + "context": "complete", + "candidateFindings": 0, + "publishedFindings": 0, + "open": 0, + "reopened": 0, + "verificationRequired": 0, + "fixed": 2, + "dismissed": 1, + "outcome": "no-findings", + "scope": "Complete native analysis of7be2810 only; the test/evidence correction needs fresh review. All non-image product jobs passed; images are not claimed accepted." + } + }, + "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.", + "actualProductFiles": { + ".vite/manifest.json": "bb13d320cf8968302162520e722f61f9290f9ca926c863b985288cb4fba2ffc3", + "assets/index-CQE4qeI0.js": "810376c1a54cbe125a3334afcc6ff1b81d14c27c089005d23f70a4da4c0dbcf4", + "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", + "index.html": "53eb4a7d9debd6dd01e421a564df3432c8d3c523a74acbf1f962c9714cb56911" + }, + "wheelSHA256": "11453b1ca6d98b08bbe718cb0b8d9fba2109bdb116cf375f66319e33fc615cd3", + "originalFailure": { + "run": 41505801643, + "aarch64Job": 125484707823, + "result": "31passed1failed", + "cause": "Current image correctly contains the newly compiled exact-revision UI, but the image test selected historical Edge.5 source271a3a0 resource hashes from the entry delivery evidence. No UI corruption or Copilot defect is inferred." + }, + "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." + }, + "controllerIncrement": { + "sourceBase": "53570f20bd8c89cd2940b5b8d5187eb8ed962428", + "goalComplete": false, + "implemented": "Application controller with verified session/capability/current-authority preflight and final access fence; independent permitted fact failures; own-member private presentation and receipt binding; exact original commands, bounded journal and five explicit mutations; read-only key/ID recovery, rejection/uncertainty/history and expiry/context/generation fencing. Pure active-membership/current-grant action availability remains separate from backend authorization.", + "frontend": { + "executions": 1009, + "newControllerAndAuthorizationExecutions": 112, + "authoredRuntimeFiles": 28, + "nonemptyRuntimeFiles": 26, + "typeOnlyFiles": 2, + "coverage": { + "lines": { + "total": 1241, + "covered": 1241, + "skipped": 0, + "pct": 100 + }, + "statements": { + "total": 1818, + "covered": 1818, + "skipped": 0, + "pct": 100 + }, + "branches": { + "total": 1314, + "covered": 1314, + "skipped": 0, + "pct": 100 + }, + "functions": { + "total": 361, + "covered": 361, + "skipped": 0, + "pct": 100 + } + }, + "independentFilesystemInventoryAudit": "passed" + }, + "sourceIdentity": { + "frontendInventorySHA256": "324066842b335df26e9624be88fb4081784ed390d9529e816191c79525e6ec52", + "backendInventorySHA256": "8497016eba6245453ac21f34a5e0bd7865a4afd6c0645be99889446ef53687b1", + "backendModules": 151, + "backendBytesUnchanged": true, + "wheelSHA256": "cbf076c8963b412ae211d00db5451f12305ce6fb8aa06b0d975e72f1f55561ec", + "currentCompiledEntryUi": { + ".vite/manifest.json": "bb13d320cf8968302162520e722f61f9290f9ca926c863b985288cb4fba2ffc3", + "assets/index-CQE4qeI0.js": "810376c1a54cbe125a3334afcc6ff1b81d14c27c089005d23f70a4da4c0dbcf4", + "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", + "index.html": "53eb4a7d9debd6dd01e421a564df3432c8d3c523a74acbf1f962c9714cb56911" + }, + "everyModuleAndCompiledUiByteVerifiedAgainstWheel": true + }, + "backend": "All151module bytes match the prior4358-test/10433-statement/2928-branch100%global/per-module evidence and verified wheel. No backend/DDL/runtime fixture or Copilot changes.", + "testsFirst": [ + "Controller suite first failed on the missing controller module; the shared pure-policy test separately failed on its missing module before extraction.", + "Two prototype controls failed before correction: a same-account replacement membership retained an old private receipt, and a protected original GET incorrectly required current aggregate-read facts. Both now pass.", + "Prototype test harness typed a fake receipt delivery too narrowly and an indexed rejected promise ambiguously. Type declarations were corrected to the actual semantic port contract; no production relaxation." + ], + "controls": [ + "Verified active membership and explicit decoded grants govern availability; labels, source data and roles do not grant an action.", + "Read permitted facts, bind private member IDs, and compare final current authorization before publication. Unavailable facts stay scoped errors; stale collected views are discarded.", + "Revalidate before human submission, preserve exact captured revision/key/payload, reject exhaustion and conflicting unresolved same-aggregate originals. Different explicit aggregates remain possible under global bounds.", + "Explicit recovery uses only GET by original action/key or known operation ID, with current original-action/operations authority and immutable original comparison. No automatic lookup on reload, mutation replay, key replacement, rebasing or lookup-only revision/CSRF preconditions.", + "Saved processing is independent of current aggregate facts and notification delivery. Post-effect journal failure retains the in-memory original receipt on its current verified context.", + "Read-only local access expires without renewal; close/context change/authority drift hide private views and journal payloads. Canceled and late reads/POST/GET responses cannot repopulate retired views.", + "Dismiss only resolved current-context requests, preserving unrelated storage, pending/uncertain originals and other contexts." + ], + "checks": { + "frontendRuntimeAndCatalogTypes": "passed", + "canonicalFrontendAndIndependentInventory": "passed", + "nodeArchitectureExecutions": 38, + "nodeArchitectureCoverage": "100%", + "originalSDK": "7originalpolicyblobsunchanged;format/consistency/catalogpass;19owners;HADM72/HADI8/HADW80 retained", + "compiledEntryBundle": "Rebuilt; all4resource hashes unchanged and byte-verified against current wheel. New controller is not yet imported by composition." + }, + "publishedCheckpoint": { + "head": "53570f20bd8c89cd2940b5b8d5187eb8ed962428", + "ci": 41507276305, + "aggregate": 125492943440, + "amd64": 125489680572, + "aarch64": 125489680631, + "productResult": "success", + "native": 41507276321, + "nativeJob": 125489680430, + "partitions": "11/11", + "context": "complete", + "candidateFindings": 0, + "publishedFindings": 0, + "open": 0, + "fixed": 2, + "dismissed": 1, + "outcome": "no-findings", + "progressFailure": { + "verification": 125491236020, + "commitJob": 125489636778, + "reference": "b9e518a3-5fe9-481a-89fb-58b2c3f0b99b", + "report": "https://github.com/vypdev/copilot/issues/828#issuecomment-6583615211" + }, + "scope": "Published test/evidence checkpoint only. New controller source needs fresh hosted acceptance; merge is held for the latest all-green condition." + }, + "localEvidence": { + "root": "/private/tmp/domus-admin-controller-53570f2", + "privateLocalOnly": true, + "scope": "Original absent-module and ownership/lookup failures, final1009-test/28-file full coverage and independent audit, types/architecture/SDK, source/UI/wheel identity and native/progress observations. Terminal generated outputs retired after compact proof." + }, + "pending": "Browser runtime/lifecycle consumption, all EN/ES administration forms and Storybook/real Python-SQLite administration journeys, safe avatar-source feasibility/owner evidence when available, and complete retained HADM72/HADI8/HADW80 acceptance. Recurrence23, installed Edge.5, Stable/all-domain release and full goal remain incomplete.", + "acceptance": "These112new parameterized/controller executions are not112product journeys or complete HADM72/HADI8/HADW80 acceptance. Full administration composition/forms/Storybook/real Python-SQLite browser paths, owner HA and Stable/all-domain product remain outstanding." + }, + "scopeBindingIncrement": { + "sourceBase": "f491b572cea14e21a0fbfc8c664b110335a54f0e", + "goalComplete": false, + "validFinding": { + "id": "administration-controller:bound-household-result-requires-verified-household", + "thread": "PRRT_kwDOVa5AKs6y1OFc", + "comment": "https://github.com/vypdev/domus/pull/26#discussion_r4446869261", + "disposition": "Accepted. Working correction binds receipts to independently verified server routing scope, preserving legal original GET recovery when aggregate reads are unavailable. Fresh native confirmation is required; no manual resolution." + }, + "implemented": "Capability contract2 exposes the server-owned household routing identifier. Strict HTTP decoding rejects unbound legacy1/unknown versions and malformed scopes. Application household/policy facts, mutation replies, recovered and retained receipts must match that independent scope. No current aggregate-read precondition is added to original GET. Journal1, five command payloads and schema9 remain unchanged.", + "readOnlyCompatibility": "Both actual native/standalone APIs already allow original key/ID GET reads on valid bounded outage leases. The controller now preserves that query behavior and owned journal recovery until unchanged expiry, while every new mutation remains blocked.", + "testsFirst": [ + "Both new foreign-household recovery controls fail against f491 before the fix, with aggregate reads unavailable.", + "Both capability-v2/scope checks and both no-household-read metadata checks fail against the preceding version1 implementation after correcting a fixture method typo.", + "The initial metadata fixture called nonexistent SQLiteMembers.replace; it was corrected to the existing save API before the meaningful contract failures. No production change or defect is inferred from that harness error.", + "Two read-only frontend controls initially fail even though actual both-mode APIs return the owned receipt without database changes; controller gating is corrected to preserve that existing contract.", + "One full backend attempt lacked loopback socket permission and ended74failed/4286passed. The unchanged code then passes the complete suite with required local-network permission; the environmental failure is retained separately." + ], + "backend": { + "completeCampaignExecutions": 4360, + "additionalFocusedOutageExecutions": 4, + "focusScope": "Four new native/standalone x key/ID read-only GET cases, added after the complete campaign; production module bytes remain identical. These are separate executions, not a claimed4364-test single full run.", + "authoredModules": 151, + "coverageTotals": { + "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" + }, + "frontend": { + "executions": 1020, + "newScopeAndGraceExecutions": 11, + "authoredRuntimeFiles": 28, + "coverage": { + "lines": { + "total": 1242, + "covered": 1242, + "skipped": 0, + "pct": 100 + }, + "statements": { + "total": 1820, + "covered": 1820, + "skipped": 0, + "pct": 100 + }, + "branches": { + "total": 1313, + "covered": 1313, + "skipped": 0, + "pct": 100 + }, + "functions": { + "total": 361, + "covered": 361, + "skipped": 0, + "pct": 100 + } + }, + "independentFilesystemInventoryAudit": "passed" + }, + "sourceIdentity": { + "frontendInventorySHA256": "83d8e9fa997fbb4cacede287843b1e77f8593bc11f727a8f1d4dd69a1feab44f", + "backendInventorySHA256": "fb46cd890daa577fd0149587ae39310af1df91769bb5856bfd2ec709ca402101", + "backendInventoryAlgorithm": "SHA256 of canonical sorted JSON path-to-SHA256 inventory", + "wheelSHA256": "11453b1ca6d98b08bbe718cb0b8d9fba2109bdb116cf375f66319e33fc615cd3", + "everyBackendModuleAndUiByteVerifiedAgainstWheel": true, + "compiledUI": { + ".vite/manifest.json": "bb13d320cf8968302162520e722f61f9290f9ca926c863b985288cb4fba2ffc3", + "assets/index-CQE4qeI0.js": "810376c1a54cbe125a3334afcc6ff1b81d14c27c089005d23f70a4da4c0dbcf4", + "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", + "index.html": "53eb4a7d9debd6dd01e421a564df3432c8d3c523a74acbf1f962c9714cb56911" + } + }, + "compatibility": [ + "Scope metadata derives from the same server-owned HouseholdQueries.household_id that governs original lookup; it does not claim existence, accept a client selector or grant authority.", + "Capabilities remain available without household.read; unknown household_id query selectors are rejected. Real database bytes remain unchanged.", + "Legacy unbound capability1 and unknown versions fail closed. The capability2 version change is explicit; journal1 and current command/GET shapes remain unchanged.", + "Original80workspace rows and HADM72/HADI8owner documents remain byte-identical; two independent scope/lookup scenarios raise the workspace floor to82 without inventing completed journeys.", + "Owner Edge.5/source271a3a0 and schema7 rollback remain unchanged. No DDL, gateway or Copilot resource/configuration/credential/bound changes." + ], + "checks": { + "frontendRuntimeAndCatalogTypes": "passed", + "backendTypes": "passed151modules", + "ruff": "passed374files", + "pythonArchitecture": "3kept0broken", + "nodeArchitectureExecutions": 38, + "nodeCoverage": "100%", + "packagingExecutions": 1, + "packagingScope": "Actual new wheel matches151source modules and all4compiled entry resources; test-only controls excluded. Build uses the exact locked installed hatchling1.33 backend.", + "openAPI": "Both actual composed schemas regenerated, exact int64 bounds retained; schema/body tests pass in the full backend campaign.", + "sdk": "7originalpolicyblobsunchanged;format/consistency/catalogpass;19owners;HADM72/HADI8/HADW82retained." + }, + "localEvidence": { + "root": "/private/tmp/domus-admin-scope-f491b57", + "privateLocalOnly": true, + "scope": "Original valid scope failure, metadata failures, distinct fixture/sandbox errors, actual outage GET observation, complete current coverage/source/wheel/SDK evidence and focused new API cases. Disposable terminal outputs retire after byte-verified compact evidence." + }, + "pending": "Browser runtime/lifecycle consumption, all EN/ES administration forms and Storybook/real Python-SQLite administration journeys, safe avatar-source feasibility/owner evidence when available, and complete retained HADM72/HADI8/HADW82 acceptance. Original80workspace rows remain unchanged with2 additional verification cases. Recurrence23, installed Edge.5, Stable/all-domain release and full goal remain incomplete.", + "acceptance": "The controller and adapters remain outside page composition. Unit/ASGI/packaging counts are not full administration browser, owner HA, retained-case or all-domain/Stable acceptance. Fresh actual CPU images and native finding confirmation remain required after publication." } } diff --git a/docs/household-administration.md b/docs/household-administration.md new file mode 100644 index 0000000..d69d00f --- /dev/null +++ b/docs/household-administration.md @@ -0,0 +1,50 @@ +# Household administration + +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; +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 +the public presentation, then submit its revision and an explicit alias. Null removes an alias; +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. + +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 +preferences. Saving the summary choice currently means saved with notification delivery unavailable; +it sends no message. + +The roster keeps the existing membership and alias when HA metadata is absent, denied or unavailable. +A missing name receives an unavailable representation. Matching names never merge accounts. Metadata +contains a name and source state without another member's masked HA identifier, eligibility or +private preferences. Avatar delivery is unavailable; the adapter does not fetch external pictures. +The policy directory lists only currently source-eligible accounts and requires current policy +authority. Unavailable/denied source facts produce an explicit state, rather than a verified empty list. + +Standalone access uses the signed-in account's grant. When HA denies directory access, an independently +verified own name can remain available. Native access never treats the Supervisor credential's own +account as the participant. Both deployments check current identity and local authority after source +I/O, so a late response cannot restore access lost during that read. These API/provider-fixture controls +do not establish a live owner-HA roster or the complete browser workspace. + +If a response is lost, preserve the original action and request key. Use the protected original +lookup rather than submitting another mutation. A recovered receipt explains original processing; +after restoration its effect can be absent from current state. A stale revision, lost permission or +temporary HA outage does not authorize a retry or rebase. Review current state before deciding on +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 +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. + +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. diff --git a/docs/household-api.md b/docs/household-api.md index a5bf0a0..0ba87f3 100644 --- a/docs/household-api.md +++ b/docs/household-api.md @@ -60,6 +60,58 @@ authorization, Origin, CSRF and idempotency rules still apply independently. See | PUT /api/v1/members/{member_id} | Profile/status/ordinary exception replacement | Fresh human and current member management; last-administrator safeguard | | PUT /api/v1/policy | Mapping/profile-adjustment replacement | Fresh human and current policy management; provenance and last-administrator safeguards | +The persistent native and standalone compositions additionally expose these administration routes. +The full administration UI and its separate browser journal remain under development. + +| Route | Meaning | Current local authority | +|---|---|---| +| 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 | +| 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 | +| GET /api/v1/administration/requests/{action}/{key} | Recover own original result when the operation ID was lost | Own operation read and current original-action authority | + +Preference bodies require `expected_revision`, `locale` (null, `en` or `es`) and the strict boolean +`routine_participation_summaries`. They accept no member/actor selector. Alias bodies require +`expected_revision` and `alias`; null alone clears, text trims to 1–80 Unicode code points, and +controls/invalid UTF-8 are rejected. Revision values are exact JSON integers from 1 through +9,223,372,036,854,775,807. A browser must preserve them exactly; unsafe numeric rounding is not a +supported representation. Each aggregate has its own revision. + +The original-request route admits only `household.update`, `member.update`, `policy.update`, +`member.presentation.update` and `member.preferences.update`. A missing request returns 404 with +no effect; the caller must not automatically resend or replace its key. Another administrator +cannot read an owner's original private preferences. Current original-action permission is checked +before replay or disclosure; changing a revision or payload under the same key remains a conflict. +Both lookup routes are protected reads without a mutation-only CSRF/revision requirement. + +Roster metadata has only `state`, nullable `name` and `avatar_state: "unavailable"`. States distinguish +available, confirmed absence, denied and unavailable source facts. A verified account without a name +has an unavailable label. Actual membership survives source failure. Other people's HA linkage and +ordinary exceptions retain the existing roster masking; source metadata cannot reveal those fields. +The directory contains at most 256 complete source facts before eligible-account filtering, with +nullable bounded literal names. A denied/unavailable directory returns no choices and an explicit +state, and does not certify an empty verified directory. Neither read initializes or changes records. +Both reject actor/subject query selectors. A changed membership/policy snapshot returns a conflict +requiring a fresh view; lost authority is checked before disclosure. + +Profile results explicitly report `notification_delivery: "unavailable"`. Administration lookups +separately report `event_delivery` as pending, delivered or suppressed. A delivered technical event +does not prove a notification was sent. Historical results retain their absent-current-effect +warning. The capability response specifies the separate version-1 administration journal limit +(eight records, 65,536 bytes, one hour); this advertises a contract rather than a completed UI. + +Capability contract version 2 adds the server-owned `household_id` routing scope. It binds +household/policy facts and original receipts even when current aggregate reads are unavailable or +not permitted. The identifier is neither a household-existence assertion nor a client selector or +authority grant. The current administration client rejects legacy unbound capability version 1 and +unknown versions. Original GET recovery still requires current original-action/operation authority, +without an aggregate-read permission, expected revision or mutation CSRF precondition. Journal +version 1 and all five command payload shapes remain unchanged. + The authorization permission list is explicitly scoped to `household_administration`. It is not a global authorization answer for tasks, inventory or another member's preferences. Later domain APIs must derive assignment/ownership facts on the server. A member's roster profile is the explicit base diff --git a/frontend/src/adapters/administration-http.ts b/frontend/src/adapters/administration-http.ts new file mode 100644 index 0000000..79f0d15 --- /dev/null +++ b/frontend/src/adapters/administration-http.ts @@ -0,0 +1,89 @@ +/** Fixed administration routes share the established protected transport and never resend a mutation. */ +import {EntryReadError} from '../application/ports'; +import type {AdministrationCommandResult, AdministrationOriginal, AdministrationRequest, AdministrationServer} from '../application/administration-ports'; +import {administrationCommand} from '../domain/administration-commands'; +import type {AdministrationCommand} from '../domain/administration-commands'; +import type {JournalContext} from '../domain/journal'; +import {administrationCommandBody} from './administration-json'; +import * as values from './administration-values'; +import {BoundedHTTP, reason} from './http-transport'; +import type {FetchTransport} from './http-transport'; +import type {EntryEndpoint} from './http-values'; +import {assertion, csrf, failure, household} from './http-values'; +import {check, fields, identifier, invalid} from './json-values'; + +function input(value: unknown, lookup: false): AdministrationRequest; +function input(value: unknown, lookup: true): AdministrationOriginal; +function input(value: unknown, lookup: boolean): AdministrationRequest | AdministrationOriginal { + try { + const item = fields(value, lookup ? ['key', 'command', 'operationId'] : ['key', 'command'], []); + const key = identifier(item.key), command = administrationCommand(item.command); + if (!lookup) return Object.freeze({key, command}); + return Object.freeze({key, command, operationId: item.operationId === null ? null : identifier(item.operationId)}); + } catch {return invalid();} +} +function route(command: AdministrationCommand): string { + switch (command.action) { + case 'household.update': return 'household'; + case 'member.update': return 'members/' + command.memberId; + case 'policy.update': return 'policy'; + case 'member.presentation.update': return 'administration/members/' + command.memberId + '/presentation'; + case 'member.preferences.update': return 'administration/preferences'; + } +} +export class HttpAdministrationServer implements AdministrationServer { + private readonly network: BoundedHTTP; + constructor(target: EntryEndpoint, transport: FetchTransport) {this.network = new BoundedHTTP(target, transport);} + private read(route: string, expected: JournalContext, decode: (value: unknown) => T, budget: 'standard' | 'roster' = 'standard') { + return this.network.owned(async signal => decode(await this.network.get(route, expected, signal, budget))); + } + capabilities(expected: JournalContext) {return this.read('administration/capabilities', expected, values.capabilities);} + authorization(expected: JournalContext) {return this.read('authorization', expected, raw => values.authorization(raw, expected));} + household(expected: JournalContext) {return this.read('household', expected, household);} + policy(expected: JournalContext) {return this.read('policy', expected, values.policy);} + roster(expected: JournalContext) {return this.read('administration/roster', expected, values.roster, 'roster');} + directory(expected: JournalContext) {return this.read('administration/directory', expected, values.directory, 'roster');} + presentation(memberId: string, expected: JournalContext) { + return this.network.owned(async signal => { + const id = identifier(memberId), result = values.presentation(await this.network.get('administration/members/' + id + '/presentation', expected, signal)); + check(result.memberId === id); return result; + }); + } + preferences(expected: JournalContext) {return this.read('administration/preferences', expected, values.preferences);} + original(value: AdministrationOriginal, expected: JournalContext) { + return this.network.owned(async signal => { + const original = input(value, true); + const raw = await this.network.get('administration/requests/' + original.command.action + '/' + original.key, expected, signal); + return values.receipt(raw, original, 'key'); + }); + } + operation(value: AdministrationOriginal, expected: JournalContext) { + return this.network.owned(async signal => { + const original = input(value, true); check(original.operationId !== null); + const raw = await this.network.get('operations/' + original.operationId, expected, signal); + return values.receipt(raw, original, 'operation'); + }); + } + async send(value: AdministrationRequest, expected: JournalContext): Promise { + let posted = false; + return this.network.owned(async signal => { + let original: AdministrationRequest; + try {original = input(value, false);} + catch {return Object.freeze({kind: 'rejected' as const, reason: 'invalid_input' as const, correlationId: null});} + let token: string; + try {assertion(expected); token = csrf(await this.network.get('auth/csrf', expected, signal));} + catch (error) {return Object.freeze({kind: 'rejected' as const, reason: error instanceof EntryReadError ? error.code : 'unavailable' as const, correlationId: null});} + try { + const body = administrationCommandBody(original.command); + if (signal.aborted) throw new EntryReadError('unavailable'); + posted = true; + const reply = await this.network.request(route(original.command), 'PUT', expected, body, original.key, token, signal); + if (reply.status === 200) return values.receipt(reply.value, original, 'mutation'); + if ([401,403,409,422,503].includes(reply.status)) return Object.freeze({kind: 'rejected' as const, + reason: reason(reply.status), correlationId: failure(reply.value)}); + } catch {/* A failed or unbound PUT response cannot establish absence of its effect. */} + return Object.freeze({kind: 'uncertain' as const}); + }).result.catch(() => posted ? Object.freeze({kind: 'uncertain' as const}) + : Object.freeze({kind: 'rejected' as const, reason: 'unavailable' as const, correlationId: null})); + } +} diff --git a/frontend/src/adapters/administration-journal.ts b/frontend/src/adapters/administration-journal.ts new file mode 100644 index 0000000..5f2a30c --- /dev/null +++ b/frontend/src/adapters/administration-journal.ts @@ -0,0 +1,68 @@ +/** One owned storage key, global bounds and readback fencing; no network or automatic mutation. */ +import {JournalStorageError} from '../application/ports'; +import type {AdministrationJournalPort} from '../application/administration-ports'; +import {administrationView, emptyAdministrationJournal, forgetAdministration, + inspectAdministrationJournal, prepareAdministration, recordAdministration} from '../domain/administration-journal'; +import type {AdministrationJournal, AdministrationJournalView, AdministrationOutcome} from '../domain/administration-journal'; +import type {AdministrationCommand} from '../domain/administration-commands'; +import {JournalError, JOURNAL_MAX_BYTES} from '../domain/journal'; +import type {JournalContext} from '../domain/journal'; +import type {OwnedStorage} from './session-journal'; + +export const ADMINISTRATION_JOURNAL_KEY = 'domus.administration.journal.v1'; +function decode(raw: string): unknown { + if (raw.length > JOURNAL_MAX_BYTES || new TextEncoder().encode(raw).byteLength > JOURNAL_MAX_BYTES) + throw new JournalError('journal_too_large'); + let value: unknown; + try {value = JSON.parse(raw);} catch {throw new JournalError('invalid_journal');} + if (JSON.stringify(value) !== raw) throw new JournalError('invalid_journal'); + return value; +} +export class AdministrationSessionJournal implements AdministrationJournalPort { + private active: Readonly<{journal: AdministrationJournal; encoded: string; context: JournalContext}> | null = null; + constructor(private readonly storage: OwnedStorage | undefined) {} + + private provider(): OwnedStorage { + if (this.storage === undefined) throw new JournalStorageError('storage_unavailable'); + return this.storage; + } + private read(): string | null { + try { + const raw = this.provider().getItem(ADMINISTRATION_JOURNAL_KEY); + if (raw !== null && typeof raw !== 'string') throw new JournalStorageError('storage_unavailable'); + return raw; + } catch {throw new JournalStorageError('storage_unavailable');} + } + private persist(expected: string | null, journal: AdministrationJournal, context: JournalContext): AdministrationJournalView { + this.active = null; + const view = administrationView(journal, context, journal.observedAt); + if (this.read() !== expected) throw new JournalStorageError('storage_changed'); + const encoded = JSON.stringify(journal); decode(encoded); + try {this.provider().setItem(ADMINISTRATION_JOURNAL_KEY, encoded);} + catch {throw new JournalStorageError('storage_unavailable');} + if (this.read() !== encoded) throw new JournalStorageError('storage_unconfirmed'); + this.active = {journal, encoded, context: view.context}; + return view; + } + open(context: JournalContext, now: number): AdministrationJournalView { + this.active = null; + const raw = this.read(); + const journal = raw === null ? emptyAdministrationJournal(now) : inspectAdministrationJournal(decode(raw), now); + return this.persist(raw, journal, context); + } + private change(update: (journal: AdministrationJournal, context: JournalContext) => AdministrationJournal): AdministrationJournalView { + const prior = this.active; this.active = null; + if (prior === null) throw new JournalStorageError('journal_not_open'); + return this.persist(prior.encoded, update(prior.journal, prior.context), prior.context); + } + prepare(key: string, command: AdministrationCommand, now: number): AdministrationJournalView { + return this.change((journal, context) => prepareAdministration(journal, context, key, command, now)); + } + record(key: string, outcome: AdministrationOutcome, now: number): AdministrationJournalView { + return this.change((journal, context) => recordAdministration(journal, context, key, outcome, now)); + } + forget(key: string, now: number): AdministrationJournalView { + return this.change((journal, context) => forgetAdministration(journal, context, key, now)); + } + detach(): void {this.active = null;} +} diff --git a/frontend/src/adapters/administration-json.ts b/frontend/src/adapters/administration-json.ts new file mode 100644 index 0000000..7741df3 --- /dev/null +++ b/frontend/src/adapters/administration-json.ts @@ -0,0 +1,22 @@ +/** Closed exact integer transport; no Number round-trip or dependency on raw JSON interpolation. */ +import {aliasValue, preferenceValues, revisionValue} from '../domain/administration'; +import type {Revision} from '../domain/administration'; +import {administrationCommand} from '../domain/administration-commands'; +import type {AdministrationCommand} from '../domain/administration-commands'; +export {administrationJSON, responseRevision, responseInteger} from './json-values'; + +export function aliasBody(expected: Revision, alias: unknown): string { + return '{"expected_revision":' + revisionValue(expected) + + ',"alias":' + JSON.stringify(aliasValue(alias)) + '}'; +} + +export function preferencesBody(expected: Revision, locale: unknown, summaries: unknown): string { + const values = preferenceValues(locale, summaries); + return '{"expected_revision":' + revisionValue(expected) + ',' + JSON.stringify(values).slice(1); +} + +/** Encode an already chosen original intent without normalizing or selecting its HTTP target. */ +export function administrationCommandBody(value: AdministrationCommand): string { + const {expected_revision, ...fields} = administrationCommand(value).payload; + return '{"expected_revision":' + expected_revision + ',' + JSON.stringify(fields).slice(1); +} diff --git a/frontend/src/adapters/administration-values.ts b/frontend/src/adapters/administration-values.ts new file mode 100644 index 0000000..d3c1c47 --- /dev/null +++ b/frontend/src/adapters/administration-values.ts @@ -0,0 +1,139 @@ +/** Strict projections of the actual administration wire; no source fact is an authorization rule. */ +import {aliasValue, nextRevision} from '../domain/administration'; +import {MEMBER_ROLES, MEMBER_STATUSES, MAPPING_MODES, ORDINARY_PERMISSIONS} from '../domain/administration-commands'; +import type {AdministrationCommand, PermissionDecision, ProfileDecision} from '../domain/administration-commands'; +import type {AdministrationAccess, AdministrationCapabilities, AdministrationMember, AdministrationPolicy, AdministrationReceipt, + AdministrationResult, Directory, OwnPreferences, PublicPresentation, Roster} from '../domain/administration-views'; +import type {AdministrationOriginal, AdministrationRequest} from '../application/administration-ports'; +import type {JournalContext} from '../domain/journal'; +import {authorization as entryAuthorization} from './http-values'; +import {boolean, check, choice, fields, identifier, integer, invalid, responseRevision, text} from './json-values'; + +const actions = ['household.update', 'member.update', 'policy.update', 'member.presentation.update', 'member.preferences.update'] as const; +function userId(value: unknown): string {check(typeof value === 'string' && /^[a-f0-9]{32}$/u.test(value)); return value;} +function name(value: unknown): string | null { + if (value === null) return null; + const result = text(value, 256); + check(result.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, '').length > 0 && !/\p{Cc}/u.test(result)); return result; +} +function alias(value: unknown): string | null { + try {const result = aliasValue(value); check(result === value); return result;} catch {return invalid();} +} +function decisions(value: unknown, profile: boolean): readonly (PermissionDecision | ProfileDecision)[] { + check(Array.isArray(value) && value.length <= 256); + const seen = new Set(); + return Object.freeze(value.map(raw => { + const item = fields(raw, profile ? ['role', 'permission', 'allow'] : ['permission', 'allow'], []); + const permission = choice(item.permission, ORDINARY_PERMISSIONS), allow = boolean(item.allow); + const role = profile ? choice(item.role, MEMBER_ROLES) : null, key = JSON.stringify([role, permission]); + check(!seen.has(key)); seen.add(key); + return Object.freeze(role === null ? {permission, allow} : {role, permission, allow}); + })); +} +export function member(value: unknown): AdministrationMember { + const item = fields(value, ['member_id', 'role', 'status', 'revision'], ['ha_user_id', 'exceptions']); + return Object.freeze({memberId: identifier(item.member_id), role: choice(item.role, MEMBER_ROLES), status: choice(item.status, MEMBER_STATUSES), + revision: responseRevision(item.revision), haUserId: item.ha_user_id == null ? null : userId(item.ha_user_id), + exceptions: item.exceptions == null ? null : decisions(item.exceptions, false)}); +} +export function authorization(value: unknown, expected: JournalContext): AdministrationAccess { + const basic = entryAuthorization(value, expected), item = value as Record; + return Object.freeze({...basic, member: item.member == null ? null : member(item.member), + effectiveRole: basic.state === 'active' ? choice(item.effective_role, MEMBER_ROLES) : null, + policyRevision: basic.state === 'active' ? responseRevision(item.policy_revision) : null}); +} +export function capabilities(value: unknown): AdministrationCapabilities { + const item = fields(value, ['state', 'contract_version', 'household_id', 'exact_revisions', 'actions', 'journal'], []); + check(item.state === 'available' && integer(item.contract_version, 1) === 2 && item.exact_revisions === 'json-integer-int64'); + const householdId = identifier(item.household_id); + check(Array.isArray(item.actions) && item.actions.length === actions.length); + const actual = item.actions.map(value => choice(value, actions)); check(new Set(actual).size === actions.length); + const journal = fields(item.journal, ['namespace', 'version', 'maximum_records', 'maximum_bytes', 'lifetime_ms'], []); + check(journal.namespace === 'administration' && integer(journal.version, 1) === 1 + && integer(journal.maximum_records, 1) === 8 && integer(journal.maximum_bytes, 1) === 65536 && integer(journal.lifetime_ms, 1) === 3600000); + return Object.freeze({contractVersion: 2, householdId, actions: Object.freeze(actual)}); +} +export function presentation(value: unknown): PublicPresentation { + const item = fields(value, ['member_id', 'alias', 'revision'], []); + return Object.freeze({memberId: identifier(item.member_id), alias: alias(item.alias), revision: responseRevision(item.revision)}); +} +export function preferences(value: unknown): OwnPreferences { + const item = fields(value, ['member_id', 'locale', 'routine_participation_summaries', 'revision', 'notification_delivery'], []); + check(item.notification_delivery === 'unavailable'); + return Object.freeze({memberId: identifier(item.member_id), locale: item.locale === null ? null : choice(item.locale, ['en', 'es']), + routine_participation_summaries: boolean(item.routine_participation_summaries), revision: responseRevision(item.revision), notificationDelivery: 'unavailable'}); +} +export function roster(value: unknown): Roster { + const item = fields(value, ['members', 'directory_state'], []); + check(Array.isArray(item.members) && item.members.length <= 256); + const members = item.members.map(raw => { + const row = fields(raw, ['member', 'presentation', 'metadata'], []), person = member(row.member), display = presentation(row.presentation); + check(person.memberId === display.memberId); + const meta = fields(row.metadata, ['state', 'name', 'avatar_state'], []); + const state = choice(meta.state, ['available', 'absent', 'denied', 'unavailable']); + const label = name(meta.name); check((state === 'available') === (label !== null) && meta.avatar_state === 'unavailable'); + return Object.freeze({member: person, presentation: display, metadata: Object.freeze({state, name: label, avatarState: 'unavailable' as const})}); + }); + check(new Set(members.map(row => row.member.memberId)).size === members.length); + const linked = members.map(row => row.member.haUserId).filter(id => id !== null); check(new Set(linked).size === linked.length); + return Object.freeze({members: Object.freeze(members), directoryState: choice(item.directory_state, ['available', 'denied', 'unavailable'])}); +} +export function directory(value: unknown): Directory { + const item = fields(value, ['state', 'profiles'], []), state = choice(item.state, ['available', 'denied', 'unavailable']); + check(Array.isArray(item.profiles) && item.profiles.length <= 256 && (state === 'available' || item.profiles.length === 0)); + const profiles = item.profiles.map(raw => {const row = fields(raw, ['ha_user_id', 'name'], []); return Object.freeze({haUserId: userId(row.ha_user_id), name: name(row.name)});}); + check(new Set(profiles.map(row => row.haUserId)).size === profiles.length); + return Object.freeze({state, profiles: Object.freeze(profiles)}); +} +export function policy(value: unknown): AdministrationPolicy { + const item = fields(value, ['household_id', 'mapping', 'revision', 'adjustments'], []), mapping = fields(item.mapping, ['mode', 'target_ha_user_id'], []); + const mode = choice(mapping.mode, MAPPING_MODES), target = mapping.target_ha_user_id === null ? null : userId(mapping.target_ha_user_id); + check((mode === 'all_ha_administrators') === (target === null)); + return Object.freeze({householdId: identifier(item.household_id), revision: responseRevision(item.revision), mappingMode: mode, + targetHAUserId: target, adjustments: decisions(item.adjustments, true) as readonly ProfileDecision[]}); +} +function result(value: unknown, command: AdministrationCommand): AdministrationResult { + let item: Record; + switch (command.action) { + case 'household.update': + item = fields(value, ['household_id', 'revision'], []); + return Object.freeze({householdId: identifier(item.household_id), revision: responseRevision(item.revision)}); + case 'policy.update': + item = fields(value, ['household_id', 'mapping_mode', 'revision'], []); + check(item.mapping_mode === command.payload.mapping_mode); + return Object.freeze({householdId: identifier(item.household_id), revision: responseRevision(item.revision), mappingMode: choice(item.mapping_mode, MAPPING_MODES)}); + case 'member.update': + item = fields(value, ['member_id', 'role', 'status', 'revision'], []); + check(item.member_id === command.memberId && item.role === command.payload.role && item.status === command.payload.status); + return Object.freeze({memberId: identifier(item.member_id), role: choice(item.role, MEMBER_ROLES), status: choice(item.status, MEMBER_STATUSES), revision: responseRevision(item.revision)}); + case 'member.presentation.update': { + item = fields(value, ['member_id', 'alias', 'revision'], []); + const actual = presentation(item); check(actual.memberId === command.memberId && actual.alias === aliasValue(command.payload.alias)); return actual; + } + case 'member.preferences.update': { + const actual = preferences(value); + check(actual.locale === command.payload.locale && actual.routine_participation_summaries === command.payload.routine_participation_summaries); return actual; + } + } +} +export function receipt(value: unknown, input: AdministrationRequest | AdministrationOriginal, mode: 'mutation' | 'key' | 'operation'): AdministrationReceipt { + const profile = input.command.action === 'member.presentation.update' || input.command.action === 'member.preferences.update'; + const notification = mode === 'key' || (mode === 'mutation' && profile); + const item = fields(value, ['state', 'archived_by', 'operation_id', 'created_at', 'result', 'message', 'action_required', + ...(notification ? ['notification_delivery'] : []), ...(mode === 'key' ? ['action', 'command_key', 'event_delivery'] : mode === 'operation' ? ['delivery'] : [])], []); + text(item.message, 2048); text(item.action_required, 4096); + const stage = choice(item.state, ['committed', 'historical']), archivedBy = item.archived_by === null ? null : identifier(item.archived_by); + check(stage !== 'historical' || archivedBy !== null); + if (notification) check(item.notification_delivery === 'unavailable'); + const operationId = identifier(item.operation_id), actual = result(item.result, input.command); + try {check(actual.revision === nextRevision(input.command.payload.expected_revision));} catch {return invalid();} + let delivery: 'pending' | 'delivered' | 'suppressed' | null = null; + if (mode !== 'mutation') { + if (mode === 'key') check(item.action === input.command.action && item.command_key === input.key); + const known = (input as AdministrationOriginal).operationId; check(known === null || known === operationId); + delivery = choice(mode === 'key' ? item.event_delivery : item.delivery, ['pending', 'delivered', 'suppressed'] as const); + check(stage === 'historical' ? delivery !== 'pending' : delivery !== 'suppressed'); + } + return Object.freeze({kind: 'receipt', stage, action: input.command.action, operationId, archivedBy, createdAt: integer(item.created_at, 0), + result: actual, notificationDelivery: 'unavailable', eventDelivery: delivery}); +} diff --git a/frontend/src/adapters/http-transport.ts b/frontend/src/adapters/http-transport.ts new file mode 100644 index 0000000..a64cc81 --- /dev/null +++ b/frontend/src/adapters/http-transport.ts @@ -0,0 +1,92 @@ +/** Shared same-origin, bounded-stream transport; semantic clients own all routes and effects. */ +import {EntryReadError} from '../application/ports'; +import type {EntryRead, ReadFault} from '../application/ports'; +import type {JournalContext} from '../domain/journal'; +import * as wire from './http-values'; +import type {EntryEndpoint} from './http-values'; +import {administrationJSON, ROSTER_MAX_BYTES} from './json-values'; + +export const HTTP_TIMEOUT_MS = 15_000; +export const HTTP_MAX_BYTES = 65_536; +export type FetchTransport = (path: string, init: RequestInit) => Promise; +export function reason(status: number): ReadFault | 'conflict' | 'invalid_input' { + switch (status) { + case 401: return 'authentication_required'; + case 403: return 'forbidden'; + case 409: return 'conflict'; + case 422: return 'invalid_input'; + default: return 'unavailable'; + } +} +export function readFailure(status: number, value: unknown): never { + wire.failure(value); const code = reason(status); + throw new EntryReadError(code === 'conflict' || code === 'invalid_input' ? 'invalid_response' : code); +} +async function consume(response: Response, expectedURL: string, signal: AbortSignal, maximumBytes: 65536 | 1048576): Promise { + wire.check(response.body !== null); const reader = response.body.getReader(); + const abort = () => {void reader.cancel().catch(() => {});}; + signal.addEventListener('abort', abort, {once: true}); + let complete = false; + try { + wire.check(!response.redirected && response.url === expectedURL + && !['opaque', 'opaqueredirect', 'error'].includes(response.type)); + wire.check(/^application\/json(?:\s*;\s*charset=utf-8)?$/iu.test(response.headers.get('Content-Type') ?? '') + && /(?:^|,)\s*no-store\s*(?:,|$)/iu.test(response.headers.get('Cache-Control') ?? '')); + const length = response.headers.get('Content-Length'); + wire.check(length === null || (/^\d+$/u.test(length) && Number(length) <= maximumBytes)); + const decoder = new TextDecoder('utf-8', {fatal: true}); let bytes = 0, raw = ''; + while (true) { + if (signal.aborted) throw new EntryReadError('unavailable'); + const part = await reader.read(); + if (signal.aborted) throw new EntryReadError('unavailable'); + if (part.done) break; + bytes += part.value.byteLength; wire.check(bytes <= maximumBytes); + raw += decoder.decode(part.value, {stream: true}); + } + raw += decoder.decode(); complete = true; return administrationJSON(raw, maximumBytes); + } finally { + signal.removeEventListener('abort', abort); + if (!complete) {try {await reader.cancel();} catch {/* Retain the bounded original failure. */}} + reader.releaseLock(); + } +} + +export class BoundedHTTP { + readonly target: EntryEndpoint; + constructor(target: EntryEndpoint, private readonly transport: FetchTransport) {this.target = wire.endpoint(target);} + owned(work: (signal: AbortSignal) => Promise): EntryRead { + const controller = new AbortController(); + let rejectAbort!: (error: EntryReadError) => void; + const interrupted = new Promise((_resolve, reject) => {rejectAbort = reject;}); + const cancel = () => {controller.abort(); rejectAbort(new EntryReadError('unavailable'));}; + const timer = setTimeout(cancel, HTTP_TIMEOUT_MS); + const result = (async () => { + try {return await Promise.race([work(controller.signal), interrupted]);} + catch (error) {throw error instanceof EntryReadError ? error : new EntryReadError('unavailable');} + finally {clearTimeout(timer);} + })(); + return {result, cancel}; + } + async request(route: string, method: 'GET' | 'POST' | 'PUT', expected: JournalContext | null, + body: string | null, key: string | null, token: string | null, signal: AbortSignal, budget: 'standard' | 'roster' = 'standard') { + if (signal.aborted) throw new EntryReadError('unavailable'); + wire.check(budget === 'standard' || budget === 'roster'); + const maximumBytes = budget === 'roster' ? ROSTER_MAX_BYTES : HTTP_MAX_BYTES; + const path = this.target.applicationBase + 'api/v1/' + route; + const headers = new Headers({'Accept': 'application/json'}); + if (expected !== null) headers.set('X-Domus-Context', wire.assertion(expected)); + if (body !== null) headers.set('Content-Type', 'application/json'); + if (token !== null) headers.set('X-CSRF-Token', token); + if (key !== null) headers.set('Idempotency-Key', key); + const transport = this.transport; + const response = await transport(path, {method, headers, body, signal, + mode: 'same-origin', credentials: 'same-origin', redirect: 'error', cache: 'no-store', referrerPolicy: 'no-referrer'}); + const value = await consume(response, this.target.origin + path, signal, maximumBytes); + return {status: response.status, value}; + } + async get(route: string, expected: JournalContext | null, signal: AbortSignal, budget: 'standard' | 'roster' = 'standard') { + const reply = await this.request(route, 'GET', expected, null, null, null, signal, budget); + if (reply.status !== 200) readFailure(reply.status, reply.value); + return reply.value; + } +} diff --git a/frontend/src/adapters/http-values.ts b/frontend/src/adapters/http-values.ts index a86f628..9b59ac4 100644 --- a/frontend/src/adapters/http-values.ts +++ b/frontend/src/adapters/http-values.ts @@ -1,33 +1,12 @@ -import {EntryReadError} from '../application/ports'; import type {EntryOperation, EntryReceipt, HouseholdFacts, VerifiedEntrySession} from '../application/ports'; import {setupPayload} from '../domain/entry'; import type {Authorization, Deployment} from '../domain/entry'; import type {JournalCommand, JournalContext} from '../domain/journal'; +import {check, invalid, fields, identifier, text, integer, boolean, choice, responseRevision} from './json-values'; +import type {Fields} from './json-values'; +export {check, invalid, fields, identifier, wireJSON} from './json-values'; export interface EntryEndpoint {readonly origin: string; readonly deployment: Deployment; readonly applicationBase: string;} -type Fields = Record; -export function invalid(): never {throw new EntryReadError('invalid_response');} -export function check(condition: unknown): asserts condition {if (!condition) invalid();} -export function fields(value: unknown, required: readonly string[], optional: readonly string[]): Fields { - check(typeof value === 'object' && value !== null && !Array.isArray(value)); - check(required.every(key => Object.hasOwn(value, key)) - && Object.keys(value).every(key => required.includes(key) || optional.includes(key))); - return value as Fields; -} -function text(value: unknown, maximum: number): string { - check(typeof value === 'string' && value.length > 0 && Array.from(value).length <= maximum - && !/[\uD800-\uDFFF]/u.test(value)); return value; -} -function integer(value: unknown, minimum: number): number { - check(typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum); return value; -} -function boolean(value: unknown): boolean {check(typeof value === 'boolean'); return value;} -function choice(value: unknown, options: readonly T[]): T { - check(typeof value === 'string' && options.includes(value as T)); return value as T; -} -export function identifier(value: unknown): string { - check(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u.test(value)); return value; -} export function endpoint(value: EntryEndpoint): EntryEndpoint { const item = fields(value, ['origin', 'deployment', 'applicationBase'], []); const origin = text(item.origin, 2048); let url: URL; @@ -47,39 +26,6 @@ export function assertion(value: JournalContext): string { return `v1:${item.installationId}:${item.channel}:${item.subject}`; } -/** Bounded wire JSON: duplicate decoded keys/deep containers/unsafe numeric lexemes fail before coercion. */ -export function wireJSON(raw: string): unknown { - const stack: (Set | null)[] = []; - try { - let index = 0; - while (index < raw.length) { - const character = raw[index++]; - if (character === '{' || character === '[') { - stack.push(character === '{' ? new Set() : null); check(stack.length <= 16); - } else if (character === '}' || character === ']') stack.pop(); - else if (character === '"') { - const start = index - 1; - while (index < raw.length) { - const next = raw[index++]; - if (next === '\\') index++; - else if (next === '"') break; - } - let following = index; - while (/\s/u.test(raw[following] ?? '')) following++; - if (raw[following] === ':') { - const keys = stack[stack.length - 1], key: unknown = JSON.parse(raw.slice(start, index)); - check(keys instanceof Set && typeof key === 'string' && !keys.has(key)); keys.add(key); - } - } else if (character === '-' || (character >= '0' && character <= '9')) { - const start = index - 1; - while (index < raw.length && /[^\s{}\[\],:"]/u.test(raw[index])) index++; - const token = raw.slice(start, index); - check(/^-?(?:0|[1-9]\d*)$/u.test(token) && token !== '-0' && Number.isSafeInteger(Number(token))); - } - } - return JSON.parse(raw); - } catch {return invalid();} -} function guidance(item: Fields): void {text(item.message, 2048); text(item.action_required, 4096);} export function session(value: unknown, target: EntryEndpoint): VerifiedEntrySession { const item = fields(value, ['state', 'ha_user_id', 'ha_administrator', 'access', 'expires_in', @@ -103,7 +49,7 @@ const administration = ['household.read', 'household.manage', 'members.read', 'm 'policy.manage', 'preferences.read', 'preferences.write', 'operations.read'] as const; function member(value: unknown, state: string, expected: JournalContext): void { const item = fields(value, ['member_id', 'role', 'status', 'revision'], ['ha_user_id', 'exceptions']); - identifier(item.member_id); choice(item.role, roles); integer(item.revision, 1); check(item.status === state); + identifier(item.member_id); choice(item.role, roles); responseRevision(item.revision); check(item.status === state); if (item.ha_user_id != null) check(item.ha_user_id === expected.subject); if (item.exceptions != null) { check(Array.isArray(item.exceptions) && item.exceptions.length <= 256); @@ -127,7 +73,7 @@ export function authorization(value: unknown, expected: JournalContext): Authori check(new Set(permissions).size === permissions.length); if (state === 'unconfigured' || state === 'not_enrolled') check(item.member == null); else member(item.member, state, expected); - if (state === 'active') {integer(item.policy_revision, 1); choice(item.effective_role, roles);} + if (state === 'active') {responseRevision(item.policy_revision); choice(item.effective_role, roles);} else check(permissions.length === 0 && item.policy_revision == null && item.effective_role == null); return Object.freeze({state, setupAllowed, enrollmentAllowed, permissions: Object.freeze(permissions)}); } @@ -135,8 +81,8 @@ export function household(value: unknown): HouseholdFacts { const item = fields(value, ['household_id', 'name', 'time_zone', 'revision'], []); const name = text(item.name, 160), timeZone = text(item.time_zone, 128); // Read compatibility preserves existing DEL/C1 values; new setup uses its stronger input policy. - check(name.trim().length > 0 && !/[\u0000-\u001F]/u.test(name + timeZone)); - return Object.freeze({householdId: identifier(item.household_id), name, timeZone, revision: integer(item.revision, 1)}); + check(name.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, '').length > 0 && !/[\u0000-\u001F]/u.test(name + timeZone)); + return Object.freeze({householdId: identifier(item.household_id), name, timeZone, revision: responseRevision(item.revision)}); } export function command(value: JournalCommand & Readonly<{key: string}>): JournalCommand & Readonly<{key: string}> { const item = fields(value, ['action', 'payload', 'key'], []), key = identifier(item.key); @@ -166,10 +112,11 @@ export function receipt(value: unknown, action: JournalCommand['action'], operat check(action === 'enroll'); result = fields(item.result, ['member_id', 'status', 'role', 'revision', 'policy_revision'], []); choice(result.status, ['pending', 'active']); check(result.role === 'viewer'); } - identifier(result.member_id); check(result.revision === 1); integer(result.policy_revision, 1); + identifier(result.member_id); check(responseRevision(result.revision) === '1'); + const policyRevision = responseRevision(result.policy_revision); const decoded: EntryReceipt = Object.freeze({kind: 'receipt', stage, archivedBy, operationId: identifier(item.operation_id), createdAt: integer(item.created_at, 0), - result: Object.freeze({...result}) as EntryReceipt['result']}); + result: Object.freeze({...result, revision: '1', policy_revision: policyRevision}) as EntryReceipt['result']}); if (!operation) return decoded; const delivery = choice(item.delivery, ['pending', 'delivered', 'suppressed']); check(stage === 'historical' ? delivery !== 'pending' : delivery !== 'suppressed'); diff --git a/frontend/src/adapters/http.ts b/frontend/src/adapters/http.ts index 8782389..39ac468 100644 --- a/frontend/src/adapters/http.ts +++ b/frontend/src/adapters/http.ts @@ -1,121 +1,47 @@ import {EntryReadError} from '../application/ports'; -import type {CommandResult, EntryOperation, EntryRead, EntryServer, ReadFault} from '../application/ports'; +import type {CommandResult, EntryOperation, EntryServer} from '../application/ports'; import type {JournalCommand, JournalContext} from '../domain/journal'; import * as wire from './http-values'; import type {EntryEndpoint} from './http-values'; - -export const HTTP_TIMEOUT_MS = 15_000; -export const HTTP_MAX_BYTES = 65_536; -export type FetchTransport = (path: string, init: RequestInit) => Promise; -function reason(status: number): ReadFault | 'conflict' | 'invalid_input' { - switch (status) { - case 401: return 'authentication_required'; - case 403: return 'forbidden'; - case 409: return 'conflict'; - case 422: return 'invalid_input'; - default: return 'unavailable'; - } -} -function readFailure(status: number, value: unknown): never { - wire.failure(value); const code = reason(status); - throw new EntryReadError(code === 'conflict' || code === 'invalid_input' ? 'invalid_response' : code); -} -async function consume(response: Response, expectedURL: string, signal: AbortSignal): Promise { - wire.check(response.body !== null); const reader = response.body.getReader(); - const abort = () => {void reader.cancel().catch(() => {});}; - signal.addEventListener('abort', abort, {once: true}); - let complete = false; - try { - wire.check(!response.redirected && response.url === expectedURL - && !['opaque', 'opaqueredirect', 'error'].includes(response.type)); - wire.check(/^application\/json(?:\s*;\s*charset=utf-8)?$/iu.test(response.headers.get('Content-Type') ?? '') - && /(?:^|,)\s*no-store\s*(?:,|$)/iu.test(response.headers.get('Cache-Control') ?? '')); - const length = response.headers.get('Content-Length'); - wire.check(length === null || (/^\d+$/u.test(length) && Number(length) <= HTTP_MAX_BYTES)); - const decoder = new TextDecoder('utf-8', {fatal: true}); let bytes = 0, raw = ''; - while (true) { - if (signal.aborted) throw new EntryReadError('unavailable'); - const part = await reader.read(); - if (signal.aborted) throw new EntryReadError('unavailable'); - if (part.done) break; - bytes += part.value.byteLength; wire.check(bytes <= HTTP_MAX_BYTES); - raw += decoder.decode(part.value, {stream: true}); - } - raw += decoder.decode(); complete = true; return wire.wireJSON(raw); - } finally { - signal.removeEventListener('abort', abort); - if (!complete) {try {await reader.cancel();} catch {/* Retain the bounded original failure. */}} - reader.releaseLock(); - } -} +import {BoundedHTTP, reason, readFailure} from './http-transport'; +import type {FetchTransport} from './http-transport'; +export {HTTP_TIMEOUT_MS, HTTP_MAX_BYTES} from './http-transport'; +export type {FetchTransport} from './http-transport'; /** Paths are relative to a verified same-origin base; request context asserts, never selects, identity. */ export class HttpEntryServer implements EntryServer { - private readonly target: EntryEndpoint; - constructor(target: EntryEndpoint, private readonly transport: FetchTransport) {this.target = wire.endpoint(target);} - private owned(work: (signal: AbortSignal) => Promise): EntryRead { - const controller = new AbortController(); - let rejectAbort!: (error: EntryReadError) => void; - const interrupted = new Promise((_resolve, reject) => {rejectAbort = reject;}); - const cancel = () => {controller.abort(); rejectAbort(new EntryReadError('unavailable'));}; - const timer = setTimeout(cancel, HTTP_TIMEOUT_MS); - const result = (async () => { - try {return await Promise.race([work(controller.signal), interrupted]);} - catch (error) {throw error instanceof EntryReadError ? error : new EntryReadError('unavailable');} - finally {clearTimeout(timer);} - })(); - return {result, cancel}; - } - private async request(route: string, method: 'GET' | 'POST', expected: JournalContext | null, - body: string | null, key: string | null, token: string | null, signal: AbortSignal) { - if (signal.aborted) throw new EntryReadError('unavailable'); - const path = this.target.applicationBase + 'api/v1/' + route; - const headers = new Headers({'Accept': 'application/json'}); - if (expected !== null) headers.set('X-Domus-Context', wire.assertion(expected)); - if (body !== null) headers.set('Content-Type', 'application/json'); - if (token !== null) headers.set('X-CSRF-Token', token); - if (key !== null) headers.set('Idempotency-Key', key); - const transport = this.transport; - const response = await transport(path, {method, headers, body, signal, - mode: 'same-origin', credentials: 'same-origin', redirect: 'error', cache: 'no-store', referrerPolicy: 'no-referrer'}); - const value = await consume(response, this.target.origin + path, signal); - return {status: response.status, value}; - } - private async get(route: string, expected: JournalContext | null, signal: AbortSignal) { - const reply = await this.request(route, 'GET', expected, null, null, null, signal); - if (reply.status !== 200) readFailure(reply.status, reply.value); - return reply.value; - } - session() {return this.owned(async signal => { - const reply = await this.request('session', 'GET', null, null, null, null, signal); + private readonly network: BoundedHTTP; + constructor(target: EntryEndpoint, transport: FetchTransport) {this.network = new BoundedHTTP(target, transport);} + session() {return this.network.owned(async signal => { + const reply = await this.network.request('session', 'GET', null, null, null, null, signal); if (reply.status === 401) {wire.failure(reply.value); return null;} if (reply.status !== 200) readFailure(reply.status, reply.value); - return wire.session(reply.value, this.target); + return wire.session(reply.value, this.network.target); });} - authorization(expected: JournalContext) {return this.owned(async signal => - wire.authorization(await this.get('authorization', expected, signal), expected));} - household(expected: JournalContext) {return this.owned(async signal => - wire.household(await this.get('household', expected, signal)));} + authorization(expected: JournalContext) {return this.network.owned(async signal => + wire.authorization(await this.network.get('authorization', expected, signal), expected));} + household(expected: JournalContext) {return this.network.owned(async signal => + wire.household(await this.network.get('household', expected, signal)));} operation(action: JournalCommand['action'], operationId: string, expected: JournalContext) { - return this.owned(async signal => { - const result = wire.receipt(await this.get('operations/' + wire.identifier(operationId), expected, signal), action, true) as EntryOperation; + return this.network.owned(async signal => { + const result = wire.receipt(await this.network.get('operations/' + wire.identifier(operationId), expected, signal), action, true) as EntryOperation; wire.check(result.operationId === operationId); return result; }); } async send(input: JournalCommand & Readonly<{key: string}>, expected: JournalContext): Promise { let posted = false; - return this.owned(async signal => { + return this.network.owned(async signal => { let command: ReturnType, token: string; try { command = wire.command(input); wire.assertion(expected); - token = wire.csrf(await this.get('auth/csrf', expected, signal)); + token = wire.csrf(await this.network.get('auth/csrf', expected, signal)); } catch (error) { return Object.freeze({kind: 'rejected' as const, reason: error instanceof EntryReadError ? error.code : 'unavailable', correlationId: null}); } try { if (signal.aborted) throw new EntryReadError('unavailable'); posted = true; - const reply = await this.request(command.action === 'setup' ? 'household' : 'members/enrollment', + const reply = await this.network.request(command.action === 'setup' ? 'household' : 'members/enrollment', 'POST', expected, JSON.stringify(command.payload), command.key, token, signal); if (reply.status === 200) return wire.receipt(reply.value, command.action, false); if ([401, 403, 409, 422, 503].includes(reply.status)) return Object.freeze({kind: 'rejected' as const, @@ -125,11 +51,11 @@ export class HttpEntryServer implements EntryServer { }).result.catch(() => posted ? Object.freeze({kind: 'uncertain' as const}) : Object.freeze({kind: 'rejected' as const, reason: 'unavailable' as const, correlationId: null})); } - async logout() {return this.owned(async signal => { - if (this.target.deployment === 'native') throw new EntryReadError('forbidden'); - const proof = wire.session(await this.get('session', null, signal), this.target); - const token = wire.csrf(await this.get('auth/csrf', proof.context, signal)); - const reply = await this.request('auth/logout', 'POST', proof.context, '{}', null, token, signal); + async logout() {return this.network.owned(async signal => { + if (this.network.target.deployment === 'native') throw new EntryReadError('forbidden'); + const proof = wire.session(await this.network.get('session', null, signal), this.network.target); + const token = wire.csrf(await this.network.get('auth/csrf', proof.context, signal)); + const reply = await this.network.request('auth/logout', 'POST', proof.context, '{}', null, token, signal); if (reply.status !== 200) readFailure(reply.status, reply.value); return wire.logout(reply.value); }).result;} diff --git a/frontend/src/adapters/json-values.ts b/frontend/src/adapters/json-values.ts new file mode 100644 index 0000000..4f2e3a3 --- /dev/null +++ b/frontend/src/adapters/json-values.ts @@ -0,0 +1,119 @@ +/** Shared closed JSON/scalar boundary; original integer sources never round through Number. */ +import {EntryReadError} from '../application/ports'; +import {revisionValue} from '../domain/administration'; +import type {Revision} from '../domain/administration'; + +export type Fields = Record; +export function invalid(): never {throw new EntryReadError('invalid_response');} +export function check(condition: unknown): asserts condition {if (!condition) invalid();} +export function fields(value: unknown, required: readonly string[], optional: readonly string[]): Fields { + check(typeof value === 'object' && value !== null && !Array.isArray(value)); + check(required.every(key => Object.hasOwn(value, key)) + && Object.keys(value).every(key => required.includes(key) || optional.includes(key))); + return value as Fields; +} +export function text(value: unknown, maximum: number): string { + check(typeof value === 'string' && value.length > 0 && Array.from(value).length <= maximum + && !/[\uD800-\uDFFF]/u.test(value)); return value; +} +export function integer(value: unknown, minimum: number): number { + const decoded = responseInteger(value); check(decoded >= minimum); return decoded; +} +export function boolean(value: unknown): boolean {check(typeof value === 'boolean'); return value;} +export function choice(value: unknown, options: readonly T[]): T { + check(typeof value === 'string' && options.includes(value as T)); return value as T; +} +export function identifier(value: unknown): string { + check(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u.test(value)); return value; +} +/** Bounded wire JSON: duplicate decoded keys/deep containers/unsafe numeric lexemes fail before coercion. */ +export function wireJSON(raw: string): unknown { + const stack: (Set | null)[] = []; + try { + let index = 0; + while (index < raw.length) { + const character = raw[index++]; + if (character === '{' || character === '[') { + stack.push(character === '{' ? new Set() : null); check(stack.length <= 16); + } else if (character === '}' || character === ']') stack.pop(); + else if (character === '"') { + const start = index - 1; + while (index < raw.length) { + const next = raw[index++]; + if (next === '\\') index++; + else if (next === '"') break; + } + let following = index; + while (/\s/u.test(raw[following] ?? '')) following++; + if (raw[following] === ':') { + const keys = stack[stack.length - 1], key: unknown = JSON.parse(raw.slice(start, index)); + check(keys instanceof Set && typeof key === 'string' && !keys.has(key)); keys.add(key); + } + } else if (character === '-' || (character >= '0' && character <= '9')) { + const start = index - 1; + while (index < raw.length && /[^\s{}\[\],:"]/u.test(raw[index])) index++; + const token = raw.slice(start, index); + check(/^-?(?:0|[1-9]\d*)$/u.test(token) && token !== '-0' && Number.isSafeInteger(Number(token))); + } + } + return JSON.parse(raw); + } catch {return invalid();} +} +class ExactInteger { + constructor(readonly source: string) {Object.freeze(this);} +} +const MIN_INTEGER = -9_223_372_036_854_775_808n; +const MAX_INTEGER = 9_223_372_036_854_775_807n; +export const JSON_MAX_BYTES = 65_536; +export const ROSTER_MAX_BYTES = 1_048_576; + +function maskNumbers(raw: string): string { + const parts: string[] = []; let index = 0; + while (index < raw.length) { + const start = index, character = raw[index++]; + if (character === '"') { + while (index < raw.length) { + const next = raw[index++]; + if (next === '\\') index++; + else if (next === '"') break; + } + } else if (character === '-' || (character >= '0' && character <= '9')) { + while (index < raw.length && /[^\s{}\[\],:"]/u.test(raw[index])) index++; + const token = raw.slice(start, index); + check(/^-?(?:0|[1-9]\d*)$/u.test(token) && token !== '-0' + && BigInt(token) >= MIN_INTEGER && BigInt(token) <= MAX_INTEGER); + parts.push('0'); continue; + } + parts.push(raw.slice(start, index)); + } + return parts.join(''); +} + +export function administrationJSON(raw: unknown, maximumBytes: 65536 | 1048576 = JSON_MAX_BYTES): unknown { + try { + check(maximumBytes === JSON_MAX_BYTES || maximumBytes === ROSTER_MAX_BYTES); + check(typeof raw === 'string' && raw.length <= maximumBytes + && new TextEncoder().encode(raw).byteLength <= maximumBytes); + // Validate syntax, decoded-key uniqueness and nesting with the existing bounded reader. + // Mask numeric tokens only for that preflight; the original lexical source remains untouched. + wireJSON(maskNumbers(raw)); + return JSON.parse(raw, (_key, value: unknown, context?: {source?: string}): unknown => { + if (typeof value !== 'number') return value; + // Unsupported engines fail closed; a rounded Number is never accepted as original evidence. + check(typeof context?.source === 'string'); + return new ExactInteger(context.source); + }); + } catch {return invalid();} +} + +export function responseRevision(value: unknown): Revision { + check(value instanceof ExactInteger); + try {return revisionValue(value.source);} catch {return invalid();} +} + +export function responseInteger(value: unknown): number { + check(value instanceof ExactInteger); + const integer = BigInt(value.source); + check(integer >= 0n && integer <= BigInt(Number.MAX_SAFE_INTEGER)); + return Number(integer); +} diff --git a/frontend/src/application/administration-controller.ts b/frontend/src/application/administration-controller.ts new file mode 100644 index 0000000..c04d23e --- /dev/null +++ b/frontend/src/application/administration-controller.ts @@ -0,0 +1,267 @@ +/** Current-fact orchestration; neither browser state nor a stored intent grants backend authority. */ +import {administrationCommand} from '../domain/administration-commands'; +import {administrationAllowed as allowed} from '../domain/administration-authorization'; +import type {AdministrationCommand} from '../domain/administration-commands'; +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'; +import {entryDecision} from '../domain/entry'; +import type {Deployment} from '../domain/entry'; +import type {JournalContext} from '../domain/journal'; +import {EntryReadError} from './ports'; +import type {EntryClock, EntryEntropy, EntryRead, EntryServer, HouseholdFacts, ReadFault, VerifiedEntrySession} from './ports'; +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}; +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'; +type State = Readonly>>; notice: AdministrationNotice | null}>; +export type AdministrationView = State & Readonly<{busy: boolean}>; +type Intent = Readonly<{kind: 'new'; command: AdministrationCommand}> | Readonly<{kind: 'recover' | 'dismiss'; key: string}>; +type Retained = Readonly<{context: JournalContext; request: AdministrationRecord; + outcome: AdministrationCommandResult; notice: AdministrationNotice | null}>; +const retired = Symbol('retired administration read'); +function blank(phase: State['phase']): State { + return Object.freeze({phase, session: null, access: null, capabilities: null, journal: null, outcome: null, + household: null, roster: null, policy: null, directory: null, preferences: null, + errors: Object.freeze({}), notice: null}); +} +function sameContext(a: JournalContext, b: JournalContext): boolean { + return a.installationId === b.installationId && a.channel === b.channel && a.subject === b.subject; +} +function commandOf(record: AdministrationRecord): AdministrationCommand { + const {action, payload} = record; + return administrationCommand('memberId' in record ? {action, payload, memberId: record.memberId} : {action, payload}); +} +function sameAggregate(a: AdministrationCommand, b: AdministrationCommand): boolean { + return a.action === b.action && (!('memberId' in a) || ('memberId' in b && a.memberId === b.memberId)); +} +function fault(error: unknown): ReadFault {return error instanceof EntryReadError ? error.code : 'unavailable';} + +export class AdministrationController { + private state = blank('idle'); + private generation = 0; + private running = false; + private opened: AdministrationJournalView | null = null; + private retained: Retained | null = null; + private readonly reads = new Set>(); + 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});} + private update(patch: Partial): void {this.state = Object.freeze({...this.state, ...patch});} + private invalidate(): number { + this.generation++; + const reads = [...this.reads]; this.reads.clear(); + for (const read of reads) {try {read.cancel();} catch {/* The generation still retires the result. */}} + this.journal.detach(); this.opened = null; return this.generation; + } + private begin(): number {const generation = this.invalidate(); this.state = blank('loading'); return generation;} + private async read(read: EntryRead, generation: number): Promise { + this.reads.add(read); + try {const value = await read.result; if (generation !== this.generation) throw retired; return value;} + finally {this.reads.delete(read);} + } + private visible(journal: AdministrationJournalView, access: AdministrationAccess): AdministrationJournalView { + return Object.freeze({...journal, records: Object.freeze(journal.records.filter(record => allowed(access, record)))}); + } + private showJournal(journal: AdministrationJournalView): void { + this.opened = journal; this.update({journal: this.visible(journal, this.state.access!)}); + } + private block(notice: AdministrationNotice): void { + this.invalidate(); this.retained = null; this.state = Object.freeze({...blank('ready'), notice}); + } + private ensureTime(session: VerifiedEntrySession): number { + const now = this.clock.now(); + if (entryDecision(this.deployment, session, null, now).state === 'authentication_required') + throw new EntryReadError('authentication_required'); + return now; + } + private async load(generation: number): Promise { + try { + const source = await this.read(this.identity.session(), generation); + if (source === null) throw new EntryReadError('authentication_required'); + if (source.deployment !== this.deployment) throw new EntryReadError('invalid_response'); + const session = Object.freeze({...source, context: Object.freeze({...source.context})}); + this.clock.anchor(session.serverTime); this.ensureTime(session); + if (!session.householdApiAvailable) {this.update({...blank('ready'), session, notice: 'api_unavailable'}); return true;} + const access = await this.read(this.server.authorization(session.context), generation); + 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 errors: Partial> = {}; + const collect = async (key: K, factory: () => EntryRead): Promise => { + try {facts[key] = await this.read(factory(), generation);} + catch (error) {if (generation !== this.generation) throw retired; errors[key] = fault(error);} + }; + const has = (permission: string) => access.permissions.includes(permission); + await Promise.all([ + has('household.read') ? collect('household', () => this.server.household(session.context)) : undefined, + has('members.read') ? collect('roster', () => this.server.roster(session.context)) : undefined, + 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, + ]); + if (facts.preferences !== null && facts.preferences.memberId !== access.member.memberId) { + facts.preferences = null; errors.preferences = 'invalid_response'; + } + if (facts.household !== null && facts.household.householdId !== capabilities.householdId) { + facts.household = null; errors.household = 'invalid_response'; + } + if (facts.policy !== null && facts.policy.householdId !== capabilities.householdId) { + facts.policy = null; errors.policy = 'invalid_response'; + } + const current = await this.read(this.server.authorization(session.context), generation); + const now = this.ensureTime(session); + if (JSON.stringify(current) !== JSON.stringify(access)) { + this.retained = null; this.update({...blank('ready'), session, access: current, notice: 'access_changed'}); return true; + } + let notice: AdministrationNotice | null = session.access === 'read_only' ? 'read_only' : null; + try { + const opened = this.journal.open(session.context, now); + if (!sameContext(opened.context, session.context)) throw new Error(); + this.opened = opened; + } catch {this.journal.detach(); this.opened = null; notice = 'journal_unavailable';} + if (this.retained !== null && (!sameContext(this.retained.context, session.context) + || now >= this.retained.request.expiresAt || !allowed(access, this.retained.request) + || (this.retained.outcome.kind === 'receipt' && !this.bound(this.retained.outcome, this.retained.request, access, capabilities)))) + this.retained = null; + const retained = this.retained; + this.update({phase: 'ready', session, access, capabilities, ...facts, errors: Object.freeze(errors), + journal: this.opened === null ? null : this.visible(this.opened, access), + outcome: retained === null ? null : retained.outcome, notice: notice ?? retained?.notice ?? null}); + return true; + } catch (error) { + if (generation !== this.generation) return false; + this.block(fault(error)); return false; + } + } + async refresh(): Promise {if (this.state.phase !== 'closed') await this.load(this.begin());} + /** Composition supplies monotonic ticks; this never refreshes or extends a session. */ + tick(): void { + const session = this.state.session; if (session === null) return; + let now: number; + try {now = this.ensureTime(session); if (this.opened !== null && now < this.opened.observedAt) throw new Error();} + catch {this.block('authentication_required'); return;} + if (this.opened !== null) this.showJournal(Object.freeze({...this.opened, + records: Object.freeze(this.opened.records.filter(record => now < record.expiresAt))})); + if (this.retained !== null && now >= this.retained.request.expiresAt) { + this.retained = null; this.update({outcome: null}); + } + } + private context(write: boolean): JournalContext | null { + this.tick(); + return this.running || this.state.phase !== 'ready' || this.state.session === null + || (write && this.state.session.access !== 'full') || this.state.access?.state !== 'active' || this.opened === null + ? null : this.state.session.context; + } + private revision(command: AdministrationCommand): Revision | null { + switch (command.action) { + 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.preferences.update': return this.state.preferences?.revision ?? null; + } + } + private bound(receipt: AdministrationReceipt, request: AdministrationRecord, access: AdministrationAccess, scope: AdministrationCapabilities): boolean { + if (receipt.action !== request.action || (request.operationId !== null && receipt.operationId !== request.operationId)) return false; + if (request.action === 'member.preferences.update') + return 'memberId' in receipt.result && receipt.result.memberId === access.member!.memberId; + if (request.action === 'household.update' || request.action === 'policy.update') { + return 'householdId' in receipt.result && receipt.result.householdId === scope.householdId; + } + return true; + } + async submit(value: unknown): Promise { + if (this.running || this.state.phase === 'closed') return; + let command: AdministrationCommand; + try {command = administrationCommand(value);} catch {this.update({notice: 'invalid_input'}); return;} + await this.run({kind: 'new', command}); + } + recover(key: string): Promise {return this.run({kind: 'recover', key});} + 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 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; + const generation = this.begin(); + try { + 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 ((intent.kind === 'new' && this.state.session.access !== 'full') || access?.state !== 'active' || this.opened === null) return; + let request: AdministrationRecord; + if (intent.kind === 'new') { + if (!allowed(access, intent.command)) {this.update({notice: 'action_not_allowed'}); return;} + const revision = this.revision(intent.command); + if (revision === null) {this.update({notice: 'action_not_allowed'}); return;} + if (revision !== intent.command.payload.expected_revision) {this.update({notice: 'conflict'}); return;} + try {nextRevision(revision);} catch {this.update({notice: 'revision_exhausted'}); return;} + if (this.opened.records.some(record => (record.stage === 'prepared' || record.stage === 'uncertain') + && sameAggregate(record, intent.command))) {this.update({notice: 'previous_request_unresolved'}); return;} + this.showJournal(this.journal.prepare(this.entropy.requestKey(), intent.command, this.clock.now())); + request = this.opened!.records[this.opened!.records.length - 1]; + } else { + const found = this.opened.records.find(record => record.key === intent.key); + if (found === undefined) {this.update({notice: 'request_unavailable'}); return;} + if (JSON.stringify(found) !== JSON.stringify(original)) {this.update({notice: 'request_changed'}); return;} + request = found; + if (!allowed(access, request)) {this.update({notice: 'action_not_allowed'}); return;} + if (intent.kind === 'dismiss') { + if (request.stage === 'prepared' || request.stage === 'uncertain') {this.update({notice: 'previous_request_unresolved'}); return;} + this.showJournal(this.journal.forget(request.key, this.clock.now())); + if (this.retained?.request.key === request.key) {this.retained = null; this.update({outcome: null});} + return; + } + if (request.stage === 'rejected' || !access.permissions.includes('operations.read')) {this.update({notice: 'action_not_allowed'}); return;} + } + this.tick(); if (generation !== this.generation) return; + this.update({phase: 'sending', notice: null}); + let outcome: AdministrationCommandResult; + const command = commandOf(request); + if (intent.kind === 'new') { + try {outcome = await this.server.send({key: request.key, command}, context);} + catch {outcome = Object.freeze({kind: 'uncertain'});} + } else { + const input: AdministrationOriginal = Object.freeze({key: request.key, command, operationId: request.operationId}); + try {outcome = await this.read(request.operationId === null ? this.server.original(input, context) + : this.server.operation(input, context), generation);} + catch (error) { + if (generation !== this.generation) return; + const reason = fault(error); + if (reason === 'authentication_required' || reason === 'forbidden') this.block(reason); + else this.update({phase: 'ready', notice: reason === 'invalid_response' ? reason : 'check_unconfirmed'}); + return; + } + } + if (generation !== this.generation) return; + this.tick(); if (generation !== this.generation) return; + let notice: AdministrationNotice | null = null; + if (outcome.kind === 'receipt' && !this.bound(outcome, request, access, this.state.capabilities!)) { + if (intent.kind !== 'new') {this.update({phase: 'ready', notice: 'invalid_response'}); return;} + outcome = Object.freeze({kind: 'uncertain'}); notice = 'invalid_response'; + } + try {this.showJournal(this.journal.record(request.key, {stage: outcome.kind === 'receipt' ? outcome.stage + : outcome.kind === 'rejected' ? 'rejected' : 'uncertain', operationId: outcome.kind === 'receipt' ? outcome.operationId : null}, this.clock.now()));} + catch {this.journal.detach(); this.opened = null; this.update({journal: null}); notice = 'journal_unavailable';} + this.retained = Object.freeze({context, request, outcome, notice}); this.update({outcome, notice}); + if (outcome.kind === 'rejected' && (outcome.reason === 'authentication_required' || outcome.reason === 'forbidden')) { + this.block(outcome.reason); this.update({outcome}); return; + } + await this.load(this.begin()); + } catch {if (generation === this.generation) { + this.journal.detach(); this.opened = null; this.update({phase: 'ready', journal: null, notice: 'journal_unavailable'}); + }} finally {this.running = false;} + } + close(): void {this.invalidate(); this.retained = null; this.state = blank('closed');} +} diff --git a/frontend/src/application/administration-ports.ts b/frontend/src/application/administration-ports.ts new file mode 100644 index 0000000..28939ab --- /dev/null +++ b/frontend/src/application/administration-ports.ts @@ -0,0 +1,32 @@ +import type {AdministrationCommand} from '../domain/administration-commands'; +import type {AdministrationJournalView, AdministrationOutcome} from '../domain/administration-journal'; +import type {JournalContext} from '../domain/journal'; +import type {AdministrationAccess, AdministrationCapabilities, AdministrationPolicy, AdministrationReceipt, + Directory, OwnPreferences, PublicPresentation, Roster} from '../domain/administration-views'; +import type {CommandResult, EntryRead, HouseholdFacts} from './ports'; + +export interface AdministrationRequest {readonly key: string; readonly command: AdministrationCommand;} +export interface AdministrationOriginal extends AdministrationRequest {readonly operationId: string | null;} +export type AdministrationCommandResult = AdministrationReceipt | Exclude; +export interface AdministrationServer { + capabilities(expected: JournalContext): EntryRead; + authorization(expected: JournalContext): EntryRead; + household(expected: JournalContext): EntryRead; + policy(expected: JournalContext): EntryRead; + roster(expected: JournalContext): EntryRead; + directory(expected: JournalContext): EntryRead; + presentation(memberId: string, expected: JournalContext): EntryRead; + preferences(expected: JournalContext): EntryRead; + original(request: AdministrationOriginal, expected: JournalContext): EntryRead; + operation(request: AdministrationOriginal, expected: JournalContext): EntryRead; + send(request: AdministrationRequest, expected: JournalContext): Promise; +} + +/** Only a fresh server-verified context may open the current view. Storage is never authority. */ +export interface AdministrationJournalPort { + open(context: JournalContext, now: number): AdministrationJournalView; + prepare(key: string, command: AdministrationCommand, now: number): AdministrationJournalView; + record(key: string, outcome: AdministrationOutcome, now: number): AdministrationJournalView; + forget(key: string, now: number): AdministrationJournalView; + detach(): void; +} diff --git a/frontend/src/application/ports.ts b/frontend/src/application/ports.ts index 7087964..282e797 100644 --- a/frontend/src/application/ports.ts +++ b/frontend/src/application/ports.ts @@ -1,3 +1,4 @@ +import type {Revision} from '../domain/administration'; import type {Journal, JournalCommand, JournalContext, RequestStage} from '../domain/journal'; import type {Authorization, Deployment, EntrySession} from '../domain/entry'; @@ -27,7 +28,7 @@ export interface HouseholdFacts { readonly householdId: string; readonly name: string; readonly timeZone: string; - readonly revision: number; + readonly revision: Revision; } export type ReadFault = 'authentication_required' | 'forbidden' | 'unavailable' | 'invalid_response'; export class EntryReadError extends Error { diff --git a/frontend/src/domain/administration-authorization.ts b/frontend/src/domain/administration-authorization.ts new file mode 100644 index 0000000..9847ac5 --- /dev/null +++ b/frontend/src/domain/administration-authorization.ts @@ -0,0 +1,16 @@ +/** Presentation availability uses current decoded grants; every backend operation still reauthorizes. */ +import type {AdministrationAccess} from './administration-views'; +import type {AdministrationCommand} from './administration-commands'; + +export function administrationAllowed(access: AdministrationAccess, command: AdministrationCommand): boolean { + if (access.state !== 'active' || access.member === null) return false; + const has = (permission: string) => access.permissions.includes(permission); + switch (command.action) { + case 'household.update': return has('household.manage'); + case 'member.update': return has('members.manage'); + case 'policy.update': return has('policy.manage'); + case 'member.presentation.update': return has('members.manage') + || (command.memberId === access.member.memberId && has('preferences.write')); + case 'member.preferences.update': return has('preferences.write'); + } +} diff --git a/frontend/src/domain/administration-commands.ts b/frontend/src/domain/administration-commands.ts new file mode 100644 index 0000000..dc0b1b0 --- /dev/null +++ b/frontend/src/domain/administration-commands.ts @@ -0,0 +1,99 @@ +/** Closed original intents; validation never supplies a principal or a permission grant. */ +import {AdministrationError, aliasValue, preferenceValues, revisionValue} from './administration'; +import type {OwnPreferenceValues, Revision} from './administration'; + +export const MEMBER_ROLES = ['administrator', 'adult', 'restricted', 'viewer'] as const; +export const MEMBER_STATUSES = ['pending', 'active', 'suspended', 'archived'] as const; +export const MAPPING_MODES = ['all_ha_administrators', 'setup_administrator', 'selected_ha_user'] as const; +const roles = MEMBER_ROLES; +export type MemberRole = typeof roles[number]; +export type MemberStatus = 'pending' | 'active' | 'suspended' | 'archived'; +export interface PermissionDecision {readonly permission: string; readonly allow: boolean;} +export interface ProfileDecision extends PermissionDecision {readonly role: MemberRole;} +export type AdministrationCommand = + Readonly<{action: 'household.update'; payload: Readonly<{expected_revision: Revision; name: string; time_zone: string}>}> + | Readonly<{action: 'member.update'; memberId: string; payload: Readonly<{expected_revision: Revision; + role: MemberRole; status: MemberStatus; exceptions: readonly PermissionDecision[]}>}> + | Readonly<{action: 'policy.update'; payload: Readonly<{expected_revision: Revision; + mapping_mode: 'all_ha_administrators' | 'setup_administrator' | 'selected_ha_user'; + target_ha_user_id: string | null; adjustments: readonly ProfileDecision[]}>}> + | Readonly<{action: 'member.presentation.update'; memberId: string; payload: Readonly<{expected_revision: Revision; alias: string | null}>}> + | Readonly<{action: 'member.preferences.update'; payload: Readonly<{expected_revision: Revision} & OwnPreferenceValues>}>; + +export const ORDINARY_PERMISSIONS = ['household.read', 'members.read', 'preferences.read', 'preferences.write', + 'operations.read', 'tasks.read', 'tasks.claim', 'tasks.manage', 'tasks.record_for_others', + 'tasks.material_use', 'tasks.create', 'tasks.complete', 'tasks.approve', 'shopping.request', + 'shopping.manage', 'inventory.manage'] as const; +type Fields = Record; +function requireValue(condition: unknown): asserts condition { + if (!condition) throw new AdministrationError('invalid_command'); +} +function fields(value: unknown, keys: readonly string[]): Fields { + requireValue(typeof value === 'object' && value !== null && !Array.isArray(value)); + requireValue(Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key))); + return value as Fields; +} +function choice(value: unknown, choices: readonly T[]): T { + requireValue(typeof value === 'string' && choices.includes(value as T)); return value as T; +} +function identifier(value: unknown): string { + requireValue(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u.test(value)); return value; +} +function text(value: unknown, maximum: number): string { + requireValue(typeof value === 'string' && value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, '').length > 0 + && Array.from(value).length <= maximum && !/[\u0000-\u001F\uD800-\uDFFF]/u.test(value)); return value; +} +function decisions(value: unknown, profile: boolean): readonly (PermissionDecision | ProfileDecision)[] { + requireValue(Array.isArray(value) && value.length <= 256); + const seen = new Set(); + return Object.freeze(value.map(raw => { + const item = fields(raw, profile ? ['role', 'permission', 'allow'] : ['permission', 'allow']); + const permission = choice(item.permission, ORDINARY_PERMISSIONS); + requireValue(typeof item.allow === 'boolean'); + const role = profile ? choice(item.role, roles) : null; + const key = JSON.stringify([role, permission]); + requireValue(!seen.has(key)); seen.add(key); + return Object.freeze(role === null ? {permission, allow: item.allow} : {role, permission, allow: item.allow}); + })); +} +export function administrationCommand(value: unknown): AdministrationCommand { + try { + requireValue(typeof value === 'object' && value !== null && !Array.isArray(value)); + const action = (value as Fields).action; + const targeted = action === 'member.update' || action === 'member.presentation.update'; + const item = fields(value, targeted ? ['action', 'memberId', 'payload'] : ['action', 'payload']); + const memberId = targeted ? identifier(item.memberId) : null; + let body: Fields; + switch (action) { + case 'household.update': + body = fields(item.payload, ['expected_revision', 'name', 'time_zone']); + return Object.freeze({action, payload: Object.freeze({expected_revision: revisionValue(body.expected_revision), + name: text(body.name, 160), time_zone: text(body.time_zone, 128)})}); + case 'member.update': + body = fields(item.payload, ['expected_revision', 'role', 'status', 'exceptions']); + return Object.freeze({action, memberId: memberId as string, payload: Object.freeze({ + expected_revision: revisionValue(body.expected_revision), role: choice(body.role, roles), + status: choice(body.status, ['pending', 'active', 'suspended', 'archived']), + exceptions: decisions(body.exceptions, false)})}); + case 'policy.update': { + body = fields(item.payload, ['expected_revision', 'mapping_mode', 'target_ha_user_id', 'adjustments']); + const mapping_mode = choice(body.mapping_mode, ['all_ha_administrators', 'setup_administrator', 'selected_ha_user']); + requireValue(mapping_mode === 'all_ha_administrators' ? body.target_ha_user_id === null + : typeof body.target_ha_user_id === 'string' && /^[a-f0-9]{32}$/u.test(body.target_ha_user_id)); + return Object.freeze({action, payload: Object.freeze({expected_revision: revisionValue(body.expected_revision), + mapping_mode, target_ha_user_id: body.target_ha_user_id as string | null, + adjustments: decisions(body.adjustments, true) as readonly ProfileDecision[]})}); + } + case 'member.presentation.update': + body = fields(item.payload, ['expected_revision', 'alias']); + aliasValue(body.alias); // Validate without substituting normalized text for the original request. + return Object.freeze({action, memberId: memberId as string, payload: Object.freeze({ + expected_revision: revisionValue(body.expected_revision), alias: body.alias as string | null})}); + case 'member.preferences.update': + body = fields(item.payload, ['expected_revision', 'locale', 'routine_participation_summaries']); + return Object.freeze({action, payload: Object.freeze({expected_revision: revisionValue(body.expected_revision), + ...preferenceValues(body.locale, body.routine_participation_summaries)})}); + default: throw new AdministrationError('invalid_command'); + } + } catch {throw new AdministrationError('invalid_command');} +} diff --git a/frontend/src/domain/administration-journal.ts b/frontend/src/domain/administration-journal.ts new file mode 100644 index 0000000..dfab5dc --- /dev/null +++ b/frontend/src/domain/administration-journal.ts @@ -0,0 +1,116 @@ +/** Original administration intents survive retirement; views expose only the freshly verified context. */ +import {administrationCommand} from './administration-commands'; +import type {AdministrationCommand} from './administration-commands'; +import {JournalError, JOURNAL_LIFETIME, JOURNAL_MAX_RECORDS} from './journal'; +import type {JournalContext, RequestStage} from './journal'; + +export type AdministrationRecord = AdministrationCommand & Readonly<{context: JournalContext; + key: string; createdAt: number; expiresAt: number; stage: RequestStage; operationId: string | null}>; +export interface AdministrationJournal { + readonly namespace: 'administration'; readonly version: 1; readonly observedAt: number; + readonly records: readonly AdministrationRecord[]; +} +export interface AdministrationJournalView { + readonly context: JournalContext; readonly observedAt: number; readonly records: readonly AdministrationRecord[]; +} +export type AdministrationOutcome = Readonly<{stage: Exclude; operationId: string | null}>; +type Fields = Record; +function check(condition: unknown): asserts condition { + if (!condition) throw new JournalError('invalid_journal'); +} +function fields(value: unknown, required: readonly string[], optional: readonly string[] = []): Fields { + check(typeof value === 'object' && value !== null && !Array.isArray(value)); + check(required.every(key => Object.hasOwn(value, key)) + && Object.keys(value).every(key => required.includes(key) || optional.includes(key))); + return value as Fields; +} +function identifier(value: unknown): string { + check(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u.test(value)); return value; +} +function scope(value: unknown): JournalContext { + const item = fields(value, ['installationId', 'channel', 'subject']); + check(typeof item.installationId === 'string' + && /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/u.test(item.installationId) + && (item.channel === 'stable' || item.channel === 'edge') + && typeof item.subject === 'string' && /^[a-f0-9]{32}$/u.test(item.subject)); + return Object.freeze({installationId: item.installationId, channel: item.channel, subject: item.subject}); +} +function time(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) + throw new JournalError('invalid_journal_time'); + return value; +} +function same(left: JournalContext, right: JournalContext): boolean { + return left.installationId === right.installationId && left.channel === right.channel && left.subject === right.subject; +} +function record(value: unknown, observedAt: number): AdministrationRecord { + const item = fields(value, ['context', 'key', 'action', 'payload', 'createdAt', 'expiresAt', 'stage', 'operationId'], ['memberId']); + const context = scope(item.context), key = identifier(item.key); + const createdAt = time(item.createdAt), expiresAt = time(item.expiresAt); + if (createdAt > observedAt || expiresAt !== createdAt + JOURNAL_LIFETIME) + throw new JournalError('invalid_journal_time'); + check(['prepared', 'uncertain', 'committed', 'historical', 'rejected'].includes(item.stage as string)); + const known = item.stage === 'committed' || item.stage === 'historical'; + if (known) identifier(item.operationId); else check(item.operationId === null); + const input = Object.hasOwn(item, 'memberId') ? {action: item.action, memberId: item.memberId, payload: item.payload} + : {action: item.action, payload: item.payload}; + const command = administrationCommand(input); + return Object.freeze({...command, context, key, createdAt, expiresAt, + stage: item.stage as RequestStage, operationId: item.operationId as string | null}); +} +function snapshot(observedAt: number, records: readonly AdministrationRecord[]): AdministrationJournal { + return Object.freeze({namespace: 'administration', version: 1, observedAt, records: Object.freeze(records)}); +} +export function emptyAdministrationJournal(now: number): AdministrationJournal {return snapshot(time(now), []);} +export function inspectAdministrationJournal(value: unknown, now: number): AdministrationJournal { + try { + now = time(now); + const item = fields(value, ['namespace', 'version', 'observedAt', 'records']); + check(item.namespace === 'administration' && item.version === 1); + const observedAt = time(item.observedAt); + if (now < observedAt) throw new JournalError('invalid_journal_time'); + check(Array.isArray(item.records) && item.records.length <= JOURNAL_MAX_RECORDS); + const records = item.records.map(value => record(value, observedAt)); + const keys = records.map(item => JSON.stringify([item.context.installationId, item.context.channel, item.context.subject, item.key])); + if (new Set(keys).size !== records.length) throw new JournalError('duplicate_request'); + return snapshot(now, records.filter(item => now < item.expiresAt)); + } catch (error) { + if (error instanceof JournalError) throw error; + throw new JournalError('invalid_journal'); + } +} +export function administrationView(journal: AdministrationJournal, context: JournalContext, now: number): AdministrationJournalView { + const current = inspectAdministrationJournal(journal, now), selected = scope(context); + return Object.freeze({context: selected, observedAt: current.observedAt, + records: Object.freeze(current.records.filter(item => same(item.context, selected)))}); +} +export function prepareAdministration(journal: AdministrationJournal, context: JournalContext, + key: string, command: AdministrationCommand, now: number): AdministrationJournal { + const prior = inspectAdministrationJournal(journal, now), selected = scope(context); + if (prior.records.length >= JOURNAL_MAX_RECORDS) throw new JournalError('journal_capacity'); + if (prior.records.some(item => same(item.context, selected) && item.key === key)) throw new JournalError('duplicate_request'); + const next = record({...administrationCommand(command), context: selected, key, createdAt: now, + expiresAt: now + JOURNAL_LIFETIME, stage: 'prepared', operationId: null}, now); + return snapshot(now, [...prior.records, next]); +} +function target(journal: AdministrationJournal, context: JournalContext, key: string): AdministrationRecord { + const selected = scope(context); + const original = journal.records.find(item => same(item.context, selected) && item.key === key); + if (!original) throw new JournalError('request_not_found'); + return original; +} +export function recordAdministration(journal: AdministrationJournal, context: JournalContext, + key: string, outcome: AdministrationOutcome, now: number): AdministrationJournal { + const prior = inspectAdministrationJournal(journal, now), original = target(prior, context, key); + const change = fields(outcome, ['stage', 'operationId']); + check(change.stage !== 'prepared'); + const next = record({...original, ...change}, now); + if (original.operationId !== null && original.operationId !== next.operationId) throw new JournalError('original_operation_changed'); + return snapshot(now, prior.records.map(item => item === original ? next : item)); +} +export function forgetAdministration(journal: AdministrationJournal, context: JournalContext, + key: string, now: number): AdministrationJournal { + const prior = inspectAdministrationJournal(journal, now), original = target(prior, context, key); + if (original.stage === 'prepared' || original.stage === 'uncertain') throw new JournalError('unresolved_request'); + return snapshot(now, prior.records.filter(item => item !== original)); +} diff --git a/frontend/src/domain/administration-views.ts b/frontend/src/domain/administration-views.ts new file mode 100644 index 0000000..ff4fb8c --- /dev/null +++ b/frontend/src/domain/administration-views.ts @@ -0,0 +1,46 @@ +/** Verified server facts remain presentation input; these values never grant backend authority. */ +import type {Revision, OwnPreferenceValues} from './administration'; +import type {AdministrationCommand, MemberRole, MemberStatus, PermissionDecision, ProfileDecision, MAPPING_MODES} from './administration-commands'; +import type {Authorization} from './entry'; + +export type AdministrationAction = AdministrationCommand['action']; +export type MappingMode = typeof MAPPING_MODES[number]; +export type MetadataState = 'available' | 'absent' | 'denied' | 'unavailable'; +export type DirectoryState = Exclude; +export interface AdministrationMember { + readonly memberId: string; readonly role: MemberRole; readonly status: MemberStatus; + readonly revision: Revision; readonly haUserId: string | null; readonly exceptions: readonly PermissionDecision[] | null; +} +export interface AdministrationAccess extends Authorization { + readonly member: AdministrationMember | null; readonly effectiveRole: MemberRole | null; readonly policyRevision: Revision | null; +} +export interface PublicPresentation {readonly memberId: string; readonly alias: string | null; readonly revision: Revision;} +export interface OwnPreferences extends OwnPreferenceValues { + readonly memberId: string; readonly revision: Revision; readonly notificationDelivery: 'unavailable'; +} +export interface RosterMember { + readonly member: AdministrationMember; readonly presentation: PublicPresentation; + readonly metadata: Readonly<{state: MetadataState; name: string | null; avatarState: 'unavailable'}>; +} +export interface Roster {readonly members: readonly RosterMember[]; readonly directoryState: DirectoryState;} +export interface Directory { + readonly state: DirectoryState; readonly profiles: readonly Readonly<{haUserId: string; name: string | null}>[]; +} +export interface AdministrationPolicy { + readonly householdId: string; readonly revision: Revision; readonly mappingMode: MappingMode; + readonly targetHAUserId: string | null; readonly adjustments: readonly ProfileDecision[]; +} +export interface AdministrationCapabilities { + readonly contractVersion: 2; readonly householdId: string; readonly actions: readonly AdministrationAction[]; +} +export type AdministrationResult = Readonly<{revision: Revision}> & ( + Readonly<{householdId: string; mappingMode?: MappingMode}> + | Readonly<{memberId: string; role: MemberRole; status: MemberStatus}> + | Readonly<{memberId: string; alias: string | null}> + | OwnPreferences); +export interface AdministrationReceipt { + readonly kind: 'receipt'; readonly stage: 'committed' | 'historical'; readonly action: AdministrationAction; + readonly operationId: string; readonly archivedBy: string | null; readonly createdAt: number; + readonly result: AdministrationResult; readonly notificationDelivery: 'unavailable'; + readonly eventDelivery: 'pending' | 'delivered' | 'suppressed' | null; +} diff --git a/frontend/src/domain/administration.ts b/frontend/src/domain/administration.ts new file mode 100644 index 0000000..aa6c461 --- /dev/null +++ b/frontend/src/domain/administration.ts @@ -0,0 +1,48 @@ +/** Pure administration values never authenticate a person or grant a permission. */ +declare const revisionBrand: unique symbol; +export type Revision = string & {readonly [revisionBrand]: true}; +export type MemberLocale = 'en' | 'es' | null; +export interface OwnPreferenceValues { + readonly locale: MemberLocale; + readonly routine_participation_summaries: boolean; +} +export type AdministrationFault = 'invalid_revision' | 'revision_exhausted' + | 'invalid_alias' | 'invalid_preferences' | 'invalid_command'; +export class AdministrationError extends Error { + constructor(readonly code: AdministrationFault) { + super(code); this.name = 'AdministrationError'; + } +} + +const MAX_REVISION = 9_223_372_036_854_775_807n; + +/** Decimal text is the browser value, independently of the integer HTTP representation. */ +export function revisionValue(value: unknown): Revision { + if (typeof value !== 'string' || value.length === 0 || value.length > 19 + || value[0] === '0' || /[^0-9]/u.test(value) || BigInt(value) > MAX_REVISION) + throw new AdministrationError('invalid_revision'); + return value as Revision; +} + +export function nextRevision(value: Revision): Revision { + const current = BigInt(revisionValue(value)); + if (current === MAX_REVISION) throw new AdministrationError('revision_exhausted'); + return revisionValue(String(current + 1n)); +} + +export function aliasValue(value: unknown): string | null { + if (value === null) return null; + if (typeof value !== 'string' || /[\p{Cc}\uD800-\uDFFF]/u.test(value)) + throw new AdministrationError('invalid_alias'); + // Python's accepted whitespace differs from String.trim() for U+FEFF. Cc was already rejected. + const alias = value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, ''); + if (alias.length === 0 || Array.from(alias).length > 80) + throw new AdministrationError('invalid_alias'); + return alias; +} + +export function preferenceValues(locale: unknown, summaries: unknown): Readonly { + if ((locale !== null && locale !== 'en' && locale !== 'es') || typeof summaries !== 'boolean') + throw new AdministrationError('invalid_preferences'); + return Object.freeze({locale, routine_participation_summaries: summaries}); +} diff --git a/frontend/stories/entry.stories.ts b/frontend/stories/entry.stories.ts index 1387609..32354da 100644 --- a/frontend/stories/entry.stories.ts +++ b/frontend/stories/entry.stories.ts @@ -1,3 +1,4 @@ +import {revisionValue} from '../src/domain/administration'; import {html} from 'lit'; import type {Meta, StoryObj} from '@storybook/web-components-vite'; import type {EntryView} from '../src/application/entry-controller'; @@ -27,7 +28,7 @@ function fixture(example: Example, deployment: Arguments['deployment']): EntryVi operationId: example === 'historical' ? 'op_fictional' : null}; return {phase: 'ready', busy: false, session, authorization, decision: entryDecision(deployment, session, authorization, time), - household: state === 'active' ? {householdId: 'fictional-home', name: 'Maple House', timeZone: 'Europe/Madrid', revision: 1} : null, + household: state === 'active' ? {householdId: 'fictional-home', name: 'Maple House', timeZone: 'Europe/Madrid', revision: revisionValue('1')} : null, journal: example === 'grace' ? null : {version: 1, context, observedAt: time, records: example === 'uncertain' || example === 'historical' ? [record] : []}, outcome: example === 'uncertain' ? {kind: 'uncertain'} : example === 'historical' diff --git a/frontend/tests/administration-authorization.test.ts b/frontend/tests/administration-authorization.test.ts new file mode 100644 index 0000000..068f05d --- /dev/null +++ b/frontend/tests/administration-authorization.test.ts @@ -0,0 +1,26 @@ +import {expect, test} from 'vitest'; +import {administrationAllowed} from '../src/domain/administration-authorization'; +import type {AdministrationAccess} from '../src/domain/administration-views'; +import {administrationCommand} from '../src/domain/administration-commands'; +import {revisionValue} from '../src/domain/administration'; + +const own = 'member-own'; +const access: AdministrationAccess = {state: 'active', setupAllowed: false, enrollmentAllowed: false, + permissions: ['preferences.write'], policyRevision: revisionValue('1'), effectiveRole: 'administrator', + member: {memberId: own, role: 'administrator', status: 'active', revision: revisionValue('1'), haUserId: null, exceptions: null}}; +const preferences = administrationCommand({action: 'member.preferences.update', payload: {expected_revision: '1', locale: 'es', routine_participation_summaries: true}}); +const alias = (memberId: string) => administrationCommand({action: 'member.presentation.update', memberId, payload: {expected_revision: '1', alias: 'Name'}}); + +test.each(['unconfigured','not_enrolled','pending','suspended','archived'] as const)('HADW-04: granted strings and role labels cannot activate %s membership', state => { + expect(administrationAllowed({...access, state}, preferences)).toBe(false); +}); +test('HADW-02: a missing current membership does not grant owner preferences', () => { + expect(administrationAllowed({...access, member: null}, preferences)).toBe(false); +}); +test('HADW-01/02: public alias owner scope and explicit administrator permission remain separate', () => { + expect(administrationAllowed(access, preferences)).toBe(true); + expect(administrationAllowed(access, alias(own))).toBe(true); + expect(administrationAllowed(access, alias('another'))).toBe(false); + expect(administrationAllowed({...access, permissions: ['members.manage']}, alias('another'))).toBe(true); + expect(administrationAllowed({...access, permissions: []}, alias(own))).toBe(false); +}); diff --git a/frontend/tests/administration-controller.test.ts b/frontend/tests/administration-controller.test.ts new file mode 100644 index 0000000..0234f82 --- /dev/null +++ b/frontend/tests/administration-controller.test.ts @@ -0,0 +1,504 @@ +import {expect, test, vi} from 'vitest'; +import {AdministrationController} from '../src/application/administration-controller'; +import type {AdministrationCommandResult, AdministrationOriginal, AdministrationRequest} from '../src/application/administration-ports'; +import {EntryReadError} from '../src/application/ports'; +import type {EntryRead, VerifiedEntrySession} from '../src/application/ports'; +import {AdministrationSessionJournal, ADMINISTRATION_JOURNAL_KEY} from '../src/adapters/administration-journal'; +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'; + +function read(value: T): EntryRead {return {result: Promise.resolve(value), cancel: vi.fn()};} +function deferred() { + let resolve!: (value: T) => void, reject!: (value: unknown) => void; + const result = new Promise((yes, no) => {resolve = yes; reject = no;}); + return {result, resolve, reject, cancel: vi.fn()}; +} +async function settle() {for (let i = 0; i < 40; i++) 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: 1_000_000, householdApiAvailable: true}; +const member = {memberId: 'member-own', role: 'adult' as const, status: 'active' as const, revision: revisionValue('3'), + haUserId: context.subject, exceptions: []}; +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 home = {householdId: 'home', name: 'Home', timeZone: 'UTC', revision: revisionValue('7')}; +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, + 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'}}), + administrationCommand({action: 'member.update', memberId: member.memberId, payload: {expected_revision: '3', role: 'restricted', status: 'active', exceptions: []}}), + administrationCommand({action: 'policy.update', payload: {expected_revision: '5', mapping_mode: 'all_ha_administrators', target_ha_user_id: null, adjustments: []}}), + administrationCommand({action: 'member.presentation.update', memberId: member.memberId, payload: {expected_revision: '11', alias: 'Literal '}}), + administrationCommand({action: 'member.preferences.update', payload: {expected_revision: '13', locale: 'en', routine_participation_summaries: false}}), +]; +function receipt(command: AdministrationCommand): AdministrationReceipt { + const revision = revisionValue(String(BigInt(command.payload.expected_revision) + 1n)); + const result = command.action === 'household.update' ? {householdId: 'home', revision} + : command.action === 'policy.update' ? {householdId: 'home', revision, mappingMode: command.payload.mapping_mode} + : command.action === 'member.update' ? {memberId: command.memberId, role: command.payload.role, status: command.payload.status, revision} + : command.action === 'member.presentation.update' ? {memberId: command.memberId, alias: command.payload.alias, revision} + : {...preferences, locale: command.payload.locale, routine_participation_summaries: command.payload.routine_participation_summaries, revision}; + return {kind: 'receipt', stage: 'committed', action: command.action, operationId: 'op-original', archivedBy: null, + createdAt: 1000, result, eventDelivery: null, notificationDelivery: 'unavailable'}; +} +function fixture(initial = access, mode: 'native' | 'standalone' = 'standalone') { + let now = 1000; + 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()}; + const journal = new AdministrationSessionJournal(storage); + const identity = {session: vi.fn(() => read({...session, deployment: mode}))}; + 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, + 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)), + send: vi.fn((request: AdministrationRequest) => Promise.resolve(receipt(request.command))), + original: vi.fn((request: AdministrationOriginal): EntryRead => read({...receipt(request.command), eventDelivery: 'delivered' as const})), + operation: vi.fn((request: AdministrationOriginal): EntryRead => read({...receipt(request.command), eventDelivery: 'pending' as const})), + }; + const clock = {anchor: vi.fn(), now: vi.fn(() => now)}, entropy = {requestKey: vi.fn(() => 'request-original')}; + const controller = new AdministrationController(mode, identity, server, journal, clock, entropy); + return {controller, identity, server, journal, values, storage, clock, entropy, setNow: (value: number) => {now = value;}}; +} + +test.each(['native', 'standalone'] as const)('HADW-04/05: %s refresh loads only currently permitted facts and never mutates', async mode => { + const f = fixture(access, mode); expect(f.controller.view.phase).toBe('idle'); + await f.controller.refresh(); + expect(f.controller.view.phase).toBe('ready'); expect(f.controller.view.preferences).toEqual(preferences); + expect(f.controller.view.roster?.members[0].member.role).toBe('adult'); + expect(f.controller.view.access?.effectiveRole).toBe('administrator'); + expect(f.controller.view.directory).toEqual({state: 'denied', profiles: []}); + expect(f.server.authorization).toHaveBeenCalledTimes(2); expect(f.server.send).not.toHaveBeenCalled(); + expect(f.entropy.requestKey).not.toHaveBeenCalled(); expect(f.controller.view.journal?.records).toEqual([]); +}); +test.each(commands)('HADW-01/02/04/05: explicit $action persists exact original before one send', async command => { + const f = fixture(); await f.controller.refresh(); + f.server.send.mockImplementationOnce(async request => { + expect(JSON.parse(f.values.get(ADMINISTRATION_JOURNAL_KEY)!).records[0]).toMatchObject({key: request.key, ...request.command, stage: 'prepared'}); + expect(f.controller.view.phase).toBe('sending'); return receipt(request.command); + }); + await f.controller.submit(command); + expect(f.server.send).toHaveBeenCalledExactlyOnceWith({key: 'request-original', command}, context); + expect(f.controller.view.outcome).toEqual(receipt(command)); + expect(f.controller.view.journal?.records[0]).toMatchObject({stage: 'committed', operationId: 'op-original'}); + expect(f.entropy.requestKey).toHaveBeenCalledTimes(1); expect(f.controller.view.busy).toBe(false); +}); +test('C69: reload and explicit recovery use GET with the immutable original, never mutation replay', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); + await f.controller.submit(commands[4]); const original = f.controller.view.journal!.records[0]; + await f.controller.refresh(); expect(f.server.original).not.toHaveBeenCalled(); + await f.controller.recover(original.key); + expect(f.server.original).toHaveBeenCalledExactlyOnceWith({key: original.key, command: commands[4], operationId: null}, context); + expect(f.server.send).toHaveBeenCalledTimes(1); expect(f.controller.view.outcome?.kind).toBe('receipt'); + await f.controller.recover(original.key); + expect(f.server.operation).toHaveBeenCalledExactlyOnceWith({key: original.key, command: commands[4], operationId: 'op-original'}, context); + expect(f.entropy.requestKey).toHaveBeenCalledTimes(1); +}); +test('HADW-02: an unbound private preferences member is never presented', async () => { + const f = fixture(); f.server.preferences.mockReturnValue(read({...preferences, memberId: 'another-member'})); + await f.controller.refresh(); expect(f.controller.view.preferences).toBeNull(); + expect(f.controller.view.errors.preferences).toBe('invalid_response'); expect(f.controller.view.household).toEqual(home); +}); +test('C21/C72: authority drift during reads hides all collected private facts', async () => { + const f = fixture(); f.server.authorization.mockReturnValueOnce(read(access)).mockReturnValueOnce(read({...access, policyRevision: revisionValue('6')})); + await f.controller.refresh(); expect(f.controller.view.notice).toBe('access_changed'); + expect(f.controller.view.preferences).toBeNull(); expect(f.controller.view.roster).toBeNull(); +}); +test('C72: a retired pending private read cannot repopulate a closed view', async () => { + const f = fixture(), pending = deferred(); f.server.preferences.mockReturnValue(pending); + const work = f.controller.refresh(); await settle(); f.controller.close(); + expect(pending.cancel).toHaveBeenCalled(); pending.resolve(preferences); await work; + expect(f.controller.view.phase).toBe('closed'); expect(f.controller.view.preferences).toBeNull(); +}); +test('C10/C11: stale or exhausted original revision is not rebased and has no prepared effect', async () => { + const f = fixture(); await f.controller.refresh(); f.server.household.mockReturnValue(read({...home, revision: revisionValue('8')})); + await f.controller.submit(commands[0]); expect(f.controller.view.notice).toBe('conflict'); + expect(f.server.send).not.toHaveBeenCalled(); expect(f.entropy.requestKey).not.toHaveBeenCalled(); + f.server.household.mockReturnValue(read({...home, revision: revisionValue('9223372036854775807')})); await f.controller.refresh(); + await f.controller.submit({...commands[0], payload: {...commands[0].payload, expected_revision: '9223372036854775807'}}); + expect(f.controller.view.notice).toBe('revision_exhausted'); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('HADW-05: read-only outage permits local facts and an owned journal until expiry, with no mutation', async () => { + const f = fixture(); f.identity.session.mockReturnValue(read({...session, access: 'read_only'})); + await f.controller.refresh(); expect(f.controller.view.preferences).toEqual(preferences); expect(f.controller.view.journal?.records).toEqual([]); + await f.controller.submit(commands[4]); expect(f.server.send).not.toHaveBeenCalled(); + f.setNow(session.expiresAt); f.controller.tick(); expect(f.controller.view.preferences).toBeNull(); + expect(f.controller.view.notice).toBe('authentication_required'); +}); +test('C69: stale command responses cannot repopulate a new verified subject', async () => { + const f = fixture(); await f.controller.refresh(); const pending = deferred(); f.server.send.mockReturnValue(pending.result); + const sent = f.controller.submit(commands[4]); await settle(); + const nextMember = {...member, memberId: 'member-new', haUserId: 'b'.repeat(32)}; + f.identity.session.mockReturnValue(read({...session, context: {...context, subject: nextMember.haUserId}})); + f.server.authorization.mockReturnValue(read({...access, member: nextMember})); + f.server.preferences.mockReturnValue(read({...preferences, memberId: nextMember.memberId})); + await f.controller.refresh(); pending.resolve(receipt(commands[4])); await sent; + expect(f.controller.view.preferences?.memberId).toBe('member-new'); + expect(f.controller.view.session?.context.subject).toBe('b'.repeat(32)); expect(f.controller.view.outcome).toBeNull(); + expect(f.controller.view.journal?.records).toEqual([]); +}); + +test('HADW-02: a same-account replacement membership cannot retain the former private receipt', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit(commands[4]); + f.server.authorization.mockReturnValue(read({...access, member: {...member, memberId: 'replacement'}})); + f.server.preferences.mockReturnValue(read({...preferences, memberId: 'replacement'})); + await f.controller.refresh(); expect(f.controller.view.preferences?.memberId).toBe('replacement'); + expect(f.controller.view.outcome).toBeNull(); +}); +test('C05: original GET recovery requires original-action authority without a current aggregate read', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit(commands[0]); + f.server.authorization.mockReturnValue(read({...access, permissions: ['household.manage', 'operations.read']})); + await f.controller.refresh(); expect(f.controller.view.household).toBeNull(); + await f.controller.recover('request-original'); + expect(f.server.operation).toHaveBeenCalledTimes(1); expect(f.controller.view.notice).not.toBe('invalid_response'); + expect(f.controller.view.outcome?.kind).toBe('receipt'); +}); + +test.each([null, {...session, deployment: 'native' as const}, {...session, householdApiAvailable: false}, + {...session, expiresAt: 1000}])('HADW-05: unsupported or expired session facts never open administration %j', async source => { + const f = fixture(); f.identity.session.mockReturnValue(read(source)); await f.controller.refresh(); + expect(f.controller.view.notice).toBe(source === null || source.expiresAt === 1000 ? 'authentication_required' + : source.deployment === 'native' ? 'invalid_response' : 'api_unavailable'); + expect(f.server.authorization).not.toHaveBeenCalled(); expect(f.controller.view.preferences).toBeNull(); + await f.controller.submit(commands[4]); expect(f.server.send).not.toHaveBeenCalled(); +}); +test.each(['unconfigured','not_enrolled','pending','suspended','archived'] as const)('HADW-04: %s access has no workspace or journal authority', async state => { + const f = fixture({...access, state}); await f.controller.refresh(); expect(f.controller.view.notice).toBe('inactive'); + expect(f.server.capabilities).not.toHaveBeenCalled(); expect(f.controller.view.journal).toBeNull(); + await f.controller.submit(commands[4]); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('HADW-02: active access without a verified current membership fails closed', async () => { + const f = fixture({...access, member: null}); await f.controller.refresh(); expect(f.controller.view.notice).toBe('invalid_response'); + expect(f.controller.view.preferences).toBeNull(); expect(f.server.preferences).not.toHaveBeenCalled(); +}); +test.each([new EntryReadError('forbidden'), new Error('private-provider-token')])('HADW-05: session failure is a bounded safe state %j', async error => { + const f = fixture(); f.identity.session.mockReturnValue({result: Promise.reject(error), cancel: vi.fn()}); + await f.controller.refresh(); expect(f.controller.view.notice).toBe(error instanceof EntryReadError ? 'forbidden' : 'unavailable'); + expect(JSON.stringify(f.controller.view)).not.toContain('private-provider-token'); +}); +test('HADW-05: synchronous clock/source failure cannot retain a private view', async () => { + const f = fixture(); await f.controller.refresh(); f.clock.anchor.mockImplementation(() => {throw new Error('clock-private');}); + await f.controller.refresh(); expect(f.controller.view.notice).toBe('unavailable'); expect(f.controller.view.preferences).toBeNull(); +}); +test.each(['household','roster','policy','directory','preferences'] as const)('HADW-03: failed %s read remains unavailable while other allowed facts stay honest', async field => { + const f = fixture(); const failed: EntryRead = {result: Promise.reject(new EntryReadError('invalid_response')), cancel: vi.fn()}; + f.server[field].mockReturnValue(failed); + await f.controller.refresh(); expect(f.controller.view[field]).toBeNull(); expect(f.controller.view.errors[field]).toBe('invalid_response'); + expect(f.controller.view.access).toEqual(access); expect(f.controller.view.phase).toBe('ready'); +}); +test('HADW-03: unexpected provider failure does not become absence or raw detail', async () => { + const f = fixture(); f.server.roster.mockImplementation(() => {throw new Error('private-ha-detail');}); + await f.controller.refresh(); expect(f.controller.view.errors.roster).toBe('unavailable'); + expect(f.controller.view.roster).toBeNull(); expect(JSON.stringify(f.controller.view)).not.toContain('private-ha-detail'); +}); +test('HADW-04: mismatched household/policy scope is not published as a current policy', async () => { + const f = fixture(); f.server.policy.mockReturnValue(read({...policy, householdId: 'foreign-home'})); + await f.controller.refresh(); expect(f.controller.view.policy).toBeNull(); expect(f.controller.view.errors.policy).toBe('invalid_response'); +}); +test.each(commands)('C42: a role label grants no $action permission', async command => { + const f = fixture({...access, permissions: []}); await f.controller.refresh(); + expect(f.controller.view.household).toBeNull(); expect(f.server.preferences).not.toHaveBeenCalled(); + await f.controller.submit(command); expect(f.controller.view.notice).toBe('action_not_allowed'); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('HADW-01: own alias uses the owner permission, without granting another alias', async () => { + const f = fixture({...access, permissions: ['members.read', 'preferences.write']}); await f.controller.refresh(); + await f.controller.submit(commands[3]); expect(f.server.send).toHaveBeenCalledTimes(1); + await f.controller.submit({...commands[3], memberId: 'other'}); expect(f.controller.view.notice).toBe('action_not_allowed'); + expect(f.server.send).toHaveBeenCalledTimes(1); +}); +test.each(commands)('HADW-04: $action needs the target revision facts, without guessing one', async command => { + const f = fixture(); await f.controller.refresh(); + 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');}); + 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 => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit({...commands[index], memberId: 'missing'}); + expect(f.controller.view.notice).toBe('action_not_allowed'); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('C39/C44: closed invalid command never prepares credentials or creates a key', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit({...commands[4], credentials: 'forbidden-secret'}); + expect(f.controller.view.notice).toBe('invalid_input'); expect(f.entropy.requestKey).not.toHaveBeenCalled(); + expect(f.server.send).not.toHaveBeenCalled(); expect(f.values.get(ADMINISTRATION_JOURNAL_KEY)).not.toContain('forbidden-secret'); +}); +test('C67: unavailable journal storage leaves permitted reads usable and blocks a new mutation', async () => { + const f = fixture(); f.storage.getItem.mockImplementation(() => {throw new Error('storage-private');}); + await f.controller.refresh(); expect(f.controller.view.notice).toBe('journal_unavailable'); expect(f.controller.view.preferences).toEqual(preferences); + await f.controller.submit(commands[4]); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('C66: a foreign journal context cannot become the current view', async () => { + const f = fixture(); vi.spyOn(f.journal, 'open').mockReturnValue({context: {...context, subject: 'b'.repeat(32)}, observedAt: 1000, records: []}); + await f.controller.refresh(); expect(f.controller.view.notice).toBe('journal_unavailable'); expect(f.controller.view.journal).toBeNull(); +}); +test('C72: cancellation failure still retires the prior generation', async () => { + const f = fixture(), pending = deferred(); pending.cancel.mockImplementation(() => {throw new Error('cancel-failure');}); + f.identity.session.mockReturnValueOnce(pending); const work = f.controller.refresh(); await f.controller.refresh(); + pending.resolve(null); await work; expect(f.controller.view.preferences).toEqual(preferences); expect(pending.cancel).toHaveBeenCalled(); +}); +test('C72: rejection of a canceled pending fact never changes a newer view', async () => { + const f = fixture(), pending = deferred(); f.server.preferences.mockReturnValueOnce(pending); + const work = f.controller.refresh(); await settle(); await f.controller.refresh(); pending.reject(new Error('retired-private')); await work; + expect(f.controller.view.preferences).toEqual(preferences); expect(f.controller.view.notice).toBeNull(); +}); +test('HADW-05: an initial load that outlives the session cannot publish its private results', async () => { + const f = fixture(), pending = deferred(); f.server.preferences.mockReturnValue(pending); + const work = f.controller.refresh(); await settle(); f.setNow(session.expiresAt); pending.resolve(preferences); await work; + expect(f.controller.view.notice).toBe('authentication_required'); expect(f.controller.view.preferences).toBeNull(); +}); +test('C21: authority change during mutation preflight prevents preparation', async () => { + const f = fixture(); await f.controller.refresh(); f.server.authorization.mockReturnValueOnce(read(access)) + .mockReturnValueOnce(read({...access, permissions: ['preferences.read']})); + await f.controller.submit(commands[4]); expect(f.controller.view.notice).toBe('access_changed'); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('C72: a changed session context during preflight never submits the old draft', async () => { + const f = fixture(); await f.controller.refresh(); f.identity.session.mockReturnValue(read({...session, context: {...context, channel: 'stable'}})); + await f.controller.submit(commands[4]); expect(f.controller.view.notice).toBe('context_changed'); expect(f.server.send).not.toHaveBeenCalled(); +}); +test.each([{access: 'read_only' as const}, {householdApiAvailable: false}])('HADW-05: preflight session downgrade has no new effect %j', async patch => { + const f = fixture(); await f.controller.refresh(); f.identity.session.mockReturnValue(read({...session, ...patch})); + await f.controller.submit(commands[4]); expect(f.server.send).not.toHaveBeenCalled(); expect(f.entropy.requestKey).not.toHaveBeenCalled(); +}); +test('HADW-05: a missing session in preflight does not submit a formerly valid draft', async () => { + const f = fixture(); await f.controller.refresh(); f.identity.session.mockReturnValue(read(null)); + await f.controller.submit(commands[4]); expect(f.controller.view.notice).toBe('authentication_required'); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('HADW-05: inactive preflight state removes authority even if the session remains verified', async () => { + const f = fixture(); await f.controller.refresh(); f.server.authorization.mockReturnValue(read({...access, state: 'suspended'})); + await f.controller.submit(commands[4]); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('C69: duplicate interactions during preflight or POST have one original key/effect', async () => { + const f = fixture(); await f.controller.refresh(); const proof = deferred(), sent = deferred(); + f.identity.session.mockReturnValueOnce(proof); f.server.send.mockReturnValue(sent.result); + const first = f.controller.submit(commands[4]); await f.controller.submit(commands[4]); await f.controller.recover('request-original'); + proof.resolve(session); await settle(); expect(f.server.send).toHaveBeenCalledTimes(1); + await f.controller.dismiss('request-original'); await f.controller.submit(commands[4]); + sent.resolve(receipt(commands[4])); await first; expect(f.entropy.requestKey).toHaveBeenCalledTimes(1); +}); +test('C68: an unresolved original blocks the same aggregate and permits a different explicit aggregate', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); + await f.controller.submit(commands[4]); f.entropy.requestKey.mockReturnValue('different-key'); + await f.controller.submit(commands[4]); expect(f.controller.view.notice).toBe('previous_request_unresolved'); expect(f.server.send).toHaveBeenCalledTimes(1); + await f.controller.submit(commands[0]); expect(f.server.send).toHaveBeenCalledTimes(2); expect(f.controller.view.journal?.records).toHaveLength(2); +}); +test('C68: unresolved alias edits do not block a different member aggregate', async () => { + const f = fixture(); f.server.roster.mockReturnValue(read({directoryState: 'available', members: [ + {member, presentation: own, metadata: {state: 'available', name: 'Own', 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]); + f.entropy.requestKey.mockReturnValue('other-key'); await f.controller.submit({...commands[3], memberId: 'other'}); + expect(f.server.send).toHaveBeenCalledTimes(2); +}); +test.each(['authentication_required','forbidden','invalid_input','conflict','unavailable','invalid_response'] as const)( + 'HADW-05: known %s rejection is never retried or rebased', async reason => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'rejected', reason, correlationId: 'cor-safe'}); + await f.controller.submit(commands[4]); expect(f.controller.view.outcome).toEqual({kind: 'rejected', reason, correlationId: 'cor-safe'}); + expect(f.server.send).toHaveBeenCalledTimes(1); expect(f.server.original).not.toHaveBeenCalled(); + if (reason === 'forbidden' || reason === 'authentication_required') expect(f.controller.view.preferences).toBeNull(); + else expect(f.controller.view.journal?.records[0].stage).toBe('rejected'); + }); +test('C69: an unexpected lost POST response stays uncertain without leaking its error', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockRejectedValueOnce(new Error('private-response')); + await f.controller.submit(commands[4]); await f.controller.refresh(); + expect(f.controller.view.outcome).toEqual({kind: 'uncertain'}); expect(f.server.send).toHaveBeenCalledTimes(1); + expect(JSON.stringify(f.controller.view)).not.toContain('private-response'); expect(f.server.original).not.toHaveBeenCalled(); +}); +test.each([0,2,4])('HADW-01/02: unbound mutation result %s is uncertain, never a confirmed original', async index => { + const f = fixture(); await f.controller.refresh(); const original = receipt(commands[index]); + f.server.send.mockResolvedValueOnce({...original, result: index === 4 ? {...preferences, memberId: 'foreign', revision: revisionValue('14')} + : {householdId: 'foreign', revision: revisionValue('8')}}); + await f.controller.submit(commands[index]); expect(f.controller.view.notice).toBe('invalid_response'); + expect(f.controller.view.outcome).toEqual({kind: 'uncertain'}); expect(f.controller.view.journal?.records[0].operationId).toBeNull(); +}); +test('HADW-05: a receipt for a different action is not accepted as a successful submission', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce(receipt(commands[0])); + await f.controller.submit(commands[4]); expect(f.controller.view.notice).toBe('invalid_response'); expect(f.controller.view.outcome?.kind).toBe('uncertain'); +}); +test('C67: persistence failure after a valid effect does not turn the receipt into failure', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockImplementationOnce(async request => { + f.storage.setItem.mockImplementation(() => {throw new Error('after-effect');}); return receipt(request.command); + }); + await f.controller.submit(commands[4]); expect(f.controller.view.outcome?.kind).toBe('receipt'); + expect(f.controller.view.notice).toBe('journal_unavailable'); expect(f.controller.view.journal).toBeNull(); + expect(JSON.parse(f.values.get(ADMINISTRATION_JOURNAL_KEY)!).records[0].stage).toBe('prepared'); +}); +test('C67: a failed prepare cannot dispatch a mutation', async () => { + const f = fixture(); await f.controller.refresh(); f.entropy.requestKey.mockImplementation(() => {throw new Error('entropy-private');}); + await f.controller.submit(commands[4]); expect(f.controller.view.notice).toBe('journal_unavailable'); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('C72: deadline expiry after prepare preserves the original without sending it', async () => { + const f = fixture(); await f.controller.refresh(); f.entropy.requestKey.mockImplementation(() => {f.setNow(session.expiresAt); return 'deadline-key';}); + await f.controller.submit(commands[4]); expect(f.controller.view.notice).toBe('authentication_required'); expect(f.server.send).not.toHaveBeenCalled(); + expect(JSON.parse(f.values.get(ADMINISTRATION_JOURNAL_KEY)!).records[0].stage).toBe('prepared'); +}); +test('C72: deadline expiry during POST cannot disclose a late private result', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockImplementationOnce(async request => { + f.setNow(session.expiresAt); return receipt(request.command); + }); + await f.controller.submit(commands[4]); expect(f.controller.view.notice).toBe('authentication_required'); expect(f.controller.view.outcome).toBeNull(); +}); + +test('C69: unknown request keys never make a lookup or a new key', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.recover('missing'); + expect(f.controller.view.notice).toBe('request_unavailable'); expect(f.server.original).not.toHaveBeenCalled(); + expect(f.entropy.requestKey).not.toHaveBeenCalled(); +}); +test('C65: an original that expires during preflight is not looked up', async () => { + const f = fixture(); f.identity.session.mockReturnValue(read({...session, expiresAt: 10_000_000})); + await f.controller.refresh(); await f.controller.submit(commands[4]); + f.identity.session.mockImplementationOnce(() => {f.setNow(3_601_000); return read({...session, expiresAt: 10_000_000});}); + await f.controller.recover('request-original'); expect(f.controller.view.notice).toBe('request_unavailable'); + expect(f.server.original).not.toHaveBeenCalled(); expect(f.server.operation).not.toHaveBeenCalled(); +}); +test('C20/C67: a changed stored original is not silently adopted during recovery', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit(commands[4]); + const raw = JSON.parse(f.values.get(ADMINISTRATION_JOURNAL_KEY)!); raw.records[0].payload.locale = 'es'; + f.values.set(ADMINISTRATION_JOURNAL_KEY, JSON.stringify(raw)); + await f.controller.recover('request-original'); expect(f.controller.view.notice).toBe('request_changed'); expect(f.server.operation).not.toHaveBeenCalled(); +}); +test('C42: losing original-action permission hides its journal payload and blocks GET disclosure', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit(commands[4]); + f.server.authorization.mockReturnValue(read({...access, permissions: ['preferences.read', 'operations.read']})); + await f.controller.recover('request-original'); expect(f.controller.view.notice).toBe('action_not_allowed'); + expect(f.controller.view.journal?.records).toEqual([]); expect(f.controller.view.outcome).toBeNull(); expect(f.server.operation).not.toHaveBeenCalled(); +}); +test('C42: operations.read alone cannot recover an original and original authority alone is insufficient', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit(commands[4]); + f.server.authorization.mockReturnValue(read({...access, permissions: ['preferences.read', 'preferences.write']})); + await f.controller.recover('request-original'); expect(f.controller.view.notice).toBe('action_not_allowed'); expect(f.server.operation).not.toHaveBeenCalled(); +}); +test('C69: a known rejected original cannot be looked up as an uncertain effect', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'rejected', reason: 'conflict', correlationId: null}); + await f.controller.submit(commands[4]); await f.controller.recover('request-original'); + expect(f.controller.view.notice).toBe('action_not_allowed'); expect(f.server.original).not.toHaveBeenCalled(); +}); +test.each([new EntryReadError('authentication_required'),new EntryReadError('forbidden'),new EntryReadError('invalid_response'),new Error('private-get-error')])( + 'C42/C69: a failed explicit lookup %j is never converted to a mutation', async error => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); await f.controller.submit(commands[4]); + f.server.original.mockImplementationOnce(() => ({result: Promise.reject(error), cancel: vi.fn()})); + await f.controller.recover('request-original'); expect(f.server.send).toHaveBeenCalledTimes(1); + expect(f.controller.view.notice).toBe(error instanceof EntryReadError ? error.code : 'check_unconfirmed'); + expect(JSON.stringify(f.controller.view)).not.toContain('private-get-error'); + if (error instanceof EntryReadError && ['forbidden','authentication_required'].includes(error.code)) expect(f.controller.view.preferences).toBeNull(); + }); +test('C72: a retired explicit lookup cannot record a late result', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); await f.controller.submit(commands[4]); + const pending = deferred(); f.server.original.mockReturnValueOnce(pending); + const work = f.controller.recover('request-original'); await settle(); f.controller.close(); pending.resolve(receipt(commands[4])); await work; + expect(pending.cancel).toHaveBeenCalled(); expect(f.controller.view.phase).toBe('closed'); expect(f.controller.view.outcome).toBeNull(); + expect(JSON.parse(f.values.get(ADMINISTRATION_JOURNAL_KEY)!).records[0].stage).toBe('uncertain'); +}); +test('C22: a known operation ID cannot be replaced by lookup', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit(commands[4]); + f.server.operation.mockReturnValueOnce(read({...receipt(commands[4]), operationId: 'different-op', eventDelivery: 'pending'})); + await f.controller.recover('request-original'); expect(f.controller.view.notice).toBe('invalid_response'); + expect(f.controller.view.journal?.records[0].operationId).toBe('op-original'); +}); +test('HADW-02: a private lookup result remains bound to the current member', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); await f.controller.submit(commands[4]); + f.server.original.mockReturnValueOnce(read({...receipt(commands[4]), result: {householdId: 'home', revision: revisionValue('14')}, eventDelivery: 'delivered'})); + await f.controller.recover('request-original'); expect(f.controller.view.notice).toBe('invalid_response'); + expect(f.controller.view.journal?.records[0].stage).toBe('uncertain'); +}); +test('HADW-04: original household results require household-shaped facts even when local reads are unavailable', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); await f.controller.submit(commands[0]); + f.server.original.mockReturnValueOnce(read({...receipt(commands[0]), result: {...own, revision: revisionValue('8')}, eventDelivery: 'delivered'})); + await f.controller.recover('request-original'); expect(f.controller.view.notice).toBe('invalid_response'); +}); +test('C07/C59: historical lookup remains processing evidence and never recreates the effect or claims notification delivery', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); await f.controller.submit(commands[4]); + f.server.original.mockReturnValueOnce(read({...receipt(commands[4]), stage: 'historical', archivedBy: 'restore-generation', eventDelivery: 'suppressed'})); + await f.controller.recover('request-original'); expect(f.controller.view.outcome).toMatchObject({kind: 'receipt', stage: 'historical', notificationDelivery: 'unavailable'}); + expect(f.controller.view.journal?.records[0].stage).toBe('historical'); expect(f.server.send).toHaveBeenCalledTimes(1); +}); +test('C68: a prepared/uncertain request cannot be dismissed as resolved', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); await f.controller.submit(commands[4]); + await f.controller.dismiss('request-original'); expect(f.controller.view.notice).toBe('previous_request_unresolved'); expect(f.controller.view.journal?.records).toHaveLength(1); +}); +test('C68: a prepared record restored after reload remains unresolved', async () => { + const f = fixture(); f.journal.open(context,1000); f.journal.prepare('prepared', commands[4],1000); await f.controller.refresh(); + await f.controller.dismiss('prepared'); expect(f.controller.view.notice).toBe('previous_request_unresolved'); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('HADW-05: dismissing a resolved request removes only that original and its active receipt', async () => { + const f = fixture(); f.values.set('unrelated-host-key','retained'); await f.controller.refresh(); await f.controller.submit(commands[4]); + await f.controller.dismiss('request-original'); expect(f.controller.view.journal?.records).toEqual([]); expect(f.controller.view.outcome).toBeNull(); + expect(f.values.get('unrelated-host-key')).toBe('retained'); expect(f.storage.removeItem).not.toHaveBeenCalled(); expect(f.server.send).toHaveBeenCalledTimes(1); +}); +test('HADW-05: dismissing another resolved request preserves the current receipt', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit(commands[4]); f.entropy.requestKey.mockReturnValue('second'); + await f.controller.submit(commands[0]); await f.controller.dismiss('request-original'); + expect(f.controller.view.outcome).toEqual(receipt(commands[0])); expect(f.controller.view.journal?.records.map(r => r.key)).toEqual(['second']); +}); +test('C67: failed dismissal does not silently erase original processing evidence', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.submit(commands[4]); + vi.spyOn(f.journal,'forget').mockImplementationOnce(() => {throw new Error('unavailable');}); + await f.controller.dismiss('request-original'); expect(f.controller.view.notice).toBe('journal_unavailable'); + expect(JSON.parse(f.values.get(ADMINISTRATION_JOURNAL_KEY)!).records).toHaveLength(1); +}); +test('C65: one-hour expiry removes only the expired view and never extends retention', async () => { + const f = fixture(); f.identity.session.mockReturnValue(read({...session, expiresAt: 10_000_000})); + await f.controller.refresh(); await f.controller.submit(commands[4]); f.setNow(3_601_000); f.controller.tick(); + expect(f.controller.view.outcome).toBeNull(); expect(f.controller.view.journal?.records).toEqual([]); +}); +test('C65: a backward monotonic clock retires private state instead of extending retention', async () => { + const f = fixture(); f.setNow(2000); await f.controller.refresh(); f.setNow(1500); f.controller.tick(); + expect(f.controller.view.notice).toBe('authentication_required'); expect(f.controller.view.preferences).toBeNull(); +}); +test('HADW-05: a failing tick clock retires the view without renewing the session', async () => { + const f = fixture(); await f.controller.refresh(); f.clock.now.mockImplementation(() => {throw new Error('clock-private');}); f.controller.tick(); + expect(f.controller.view.notice).toBe('authentication_required'); expect(f.identity.session).toHaveBeenCalledTimes(1); +}); +test('C72: close is terminal for reads, commands, recovery and dismissal', async () => { + const f = fixture(); await f.controller.refresh(); f.controller.close(); f.controller.tick(); + await f.controller.refresh(); await f.controller.submit(commands[4]); await f.controller.recover('request-original'); await f.controller.dismiss('request-original'); + expect(f.identity.session).toHaveBeenCalledTimes(1); expect(f.server.send).not.toHaveBeenCalled(); expect(f.controller.view.phase).toBe('closed'); +}); +test('HADW-05: idle commands cannot select an identity or open storage', async () => { + const f = fixture(); await f.controller.submit(commands[4]); expect(f.identity.session).not.toHaveBeenCalled(); expect(f.storage.getItem).not.toHaveBeenCalled(); +}); + +test('C72: a lifecycle retirement during failed preparation cannot reopen the controller', async () => { + const f = fixture(); await f.controller.refresh(); f.entropy.requestKey.mockImplementation(() => {f.controller.close(); throw new Error('retired');}); + await f.controller.submit(commands[4]); expect(f.controller.view.phase).toBe('closed'); expect(f.controller.view.notice).toBeNull(); expect(f.server.send).not.toHaveBeenCalled(); +}); + +test.each([0,2])('HADW-05: a foreign household receipt %s is rejected when aggregate facts are unavailable', async index => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); await f.controller.submit(commands[index]); + f.server.household.mockImplementation(() => {throw new Error('current-household-unavailable');}); + f.server.policy.mockImplementation(() => {throw new Error('current-policy-unavailable');}); + f.server.original.mockReturnValueOnce(read({...receipt(commands[index]), result: {householdId: 'foreign', revision: revisionValue('8')}, eventDelivery: 'delivered'})); + await f.controller.recover('request-original'); expect(f.controller.view.notice).toBe('invalid_response'); + expect(f.controller.view.journal?.records[0].stage).toBe('uncertain'); expect(f.server.send).toHaveBeenCalledTimes(1); +}); + +test('HADW-05: current household facts must match independently verified routing scope', async () => { + const f = fixture(); f.server.household.mockReturnValue(read({...home, householdId: 'foreign'})); + await f.controller.refresh(); expect(f.controller.view.household).toBeNull(); expect(f.controller.view.errors.household).toBe('invalid_response'); +}); +test('HADW-05: original household recovery survives reload without any aggregate-read permission', async () => { + const f = fixture({...access, permissions: ['household.manage','operations.read']}); + f.journal.open(context,1000);f.journal.prepare('prior-original',commands[0],1000); + f.journal.record('prior-original',{stage:'committed',operationId:'op-original'},1000);f.journal.detach(); + await f.controller.refresh();await f.controller.recover('prior-original'); + expect(f.server.household).not.toHaveBeenCalled();expect(f.server.operation).toHaveBeenCalledTimes(1); + expect(f.controller.view.outcome).toEqual({...receipt(commands[0]),eventDelivery:'pending'});expect(f.server.send).not.toHaveBeenCalled(); +}); + +test.each([false,true])('C74/C82: verified read-only grace supports explicit original GET recovery with known ID %s', async known => { + const f = fixture(); await f.controller.refresh(); if (!known) f.server.send.mockResolvedValueOnce({kind:'uncertain'}); + await f.controller.submit(commands[4]);f.identity.session.mockReturnValue(read({...session,access:'read_only'})); + await f.controller.refresh();await f.controller.recover('request-original'); + expect(known?f.server.operation:f.server.original).toHaveBeenCalledTimes(1);expect(f.controller.view.outcome?.kind).toBe('receipt'); + 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(); +}); diff --git a/frontend/tests/administration-http.test.ts b/frontend/tests/administration-http.test.ts new file mode 100644 index 0000000..8811f07 --- /dev/null +++ b/frontend/tests/administration-http.test.ts @@ -0,0 +1,284 @@ +import {afterEach, expect, test, vi} from 'vitest'; +import {HttpAdministrationServer} from '../src/adapters/administration-http'; +import {administrationJSON, responseRevision, ROSTER_MAX_BYTES} from '../src/adapters/json-values'; +import {BoundedHTTP, HTTP_TIMEOUT_MS} from '../src/adapters/http-transport'; +import {administrationCommand} from '../src/domain/administration-commands'; +import type {JournalContext} from '../src/domain/journal'; + +const context: JournalContext = {installationId: '01234567-89ab-cdef-0123-456789abcdef', channel: 'edge', subject: 'a'.repeat(32)}; +const REV = '9007199254740993', NEXT = '9007199254740994'; +const encode = (value: unknown) => JSON.stringify(value).replaceAll('"$revision"', REV).replaceAll('"$next"', NEXT); +const commands = [ + {action: 'household.update', payload: {expected_revision: REV, name: 'Home', time_zone: 'UTC'}}, + {action: 'member.update', memberId: 'member-1', payload: {expected_revision: REV, role: 'adult', status: 'active', exceptions: []}}, + {action: 'policy.update', payload: {expected_revision: REV, mapping_mode: 'all_ha_administrators', target_ha_user_id: null, adjustments: []}}, + {action: 'member.presentation.update', memberId: 'member-1', payload: {expected_revision: REV, alias: ' Literal '}}, + {action: 'member.preferences.update', payload: {expected_revision: REV, locale: 'es', routine_participation_summaries: false}}, +]; +const paths = ['household', 'members/member-1', 'policy', 'administration/members/member-1/presentation', 'administration/preferences']; +const member = () => ({member_id: 'member-1', role: 'viewer', status: 'active', revision: '$revision', ha_user_id: context.subject, exceptions: [] as {permission: string; allow: boolean}[]}); +const presentation = () => ({member_id: 'member-1', alias: null as string | null, revision: '$revision'}); +const preferences = () => ({member_id: 'member-1', locale: 'es', routine_participation_summaries: false, revision: '$revision', notification_delivery: 'unavailable'}); +const policy = () => ({household_id: 'home', mapping: {mode: 'all_ha_administrators', target_ha_user_id: null}, revision: '$revision', adjustments: []}); +const capabilities = () => ({state: 'available', contract_version: 2, household_id: 'home', exact_revisions: 'json-integer-int64', actions: commands.map(c => c.action), + journal: {namespace: 'administration', version: 1, maximum_records: 8, maximum_bytes: 65536, lifetime_ms: 3600000}}); +const access = () => ({state: 'active', setup_allowed: false, enrollment_allowed: false, member: member(), policy_revision: '$revision', effective_role: 'administrator', + permission_scope: 'household_administration', permissions: ['household.read', 'members.read', 'policy.manage'], message: 'Current access.', action_required: 'Review.'}); +const roster = () => ({members: [{member: member(), presentation: presentation(), metadata: {state: 'available', name: 'HA name', avatar_state: 'unavailable'}}], directory_state: 'available'}); +const results = [ + {household_id: 'home', revision: '$next'}, {member_id: 'member-1', role: 'adult', status: 'active', revision: '$next'}, + {household_id: 'home', revision: '$next', mapping_mode: 'all_ha_administrators'}, + {member_id: 'member-1', alias: 'Literal', revision: '$next'}, + {member_id: 'member-1', locale: 'es', routine_participation_summaries: false, revision: '$next', notification_delivery: 'unavailable'}, +]; +function receipt(index: number, lookup = false) { + return {state: 'committed', archived_by: null, operation_id: 'op-1', created_at: 1000, result: results[index], message: 'Saved.', action_required: 'Review current state.', + ...(index >= 3 || lookup ? {notification_delivery: 'unavailable'} : {}), + ...(lookup ? {action: commands[index].action, command_key: 'original-key', event_delivery: 'pending'} : {})}; +} +function fixture(mode: 'native' | 'standalone' = 'standalone') { + const target = {deployment: mode, applicationBase: mode === 'native' ? '/api/hassio_ingress/' + 'b'.repeat(43) + '/' : '/', origin: 'https://domus.example'}; + const transport = vi.fn(async (_path: string, _init: RequestInit) => new Response('{}')); + const network = async (path: string, init: RequestInit) => { + const reply = await transport(path, init); + if (!reply.url) Object.defineProperty(reply, 'url', {value: target.origin + path}); return reply; + }; + return {target, transport, network, server: new HttpAdministrationServer(target, network)}; +} +function reply(value: unknown, status = 200) {return new Response(encode(value), {status, headers: {'Content-Type': 'application/json', 'Cache-Control': 'no-store'}});} +const blocked = (status: number) => reply({state: 'blocked', code: 'request_forbidden', message: 'Private detail.', action_required: 'Review.', correlation_id: 'cor_safe'}, status); +afterEach(() => vi.useRealTimers()); + +for (const mode of ['native', 'standalone'] as const) { + test(`${mode}: reads project exact values, stored/effective role provenance and honest metadata`, async () => { + const f = fixture(mode); + const wires = [capabilities(), access(), {household_id: 'home', name: 'Home', time_zone: 'UTC', revision: '$revision'}, policy(), roster(), + {state: 'available', profiles: [{ha_user_id: context.subject, name: null}]}, presentation(), preferences()]; + for (const data of wires) f.transport.mockResolvedValueOnce(reply(data)); + expect(await f.server.capabilities(context).result).toMatchObject({contractVersion: 2, householdId: 'home', actions: commands.map(c => c.action)}); + const authority = await f.server.authorization(context).result; + expect(authority).toMatchObject({member: {memberId: 'member-1', role: 'viewer', revision: REV}, effectiveRole: 'administrator', policyRevision: REV}); + expect((await f.server.household(context).result).revision).toBe(REV); + expect((await f.server.policy(context).result).revision).toBe(REV); + expect((await f.server.roster(context).result).members[0]).toMatchObject({member: {revision: REV}, metadata: {state: 'available', name: 'HA name'}}); + expect((await f.server.directory(context).result).profiles).toEqual([{haUserId: context.subject, name: null}]); + expect((await f.server.presentation('member-1', context).result).revision).toBe(REV); + expect(await f.server.preferences(context).result).toMatchObject({memberId: 'member-1', locale: 'es', revision: REV, notificationDelivery: 'unavailable'}); + for (const [path, init] of f.transport.mock.calls) { + expect(path.startsWith(f.target.applicationBase + 'api/v1/')).toBe(true); + expect(init.method).toBe('GET'); expect(new Headers(init.headers).has('X-CSRF-Token')).toBe(false); + } + }); + test.each(commands)(`${mode}: $action sends the exact original once and recovers with a GET`, async raw => { + const index = commands.indexOf(raw), command = administrationCommand(raw), f = fixture(mode), input = {key: 'original-key', command}; + f.transport.mockResolvedValueOnce(reply({csrf_token: 'c'.repeat(64)})).mockResolvedValueOnce(reply(receipt(index))).mockResolvedValueOnce(reply(receipt(index, true))); + const saved = await f.server.send(input, context); + expect(saved).toMatchObject({kind: 'receipt', action: command.action, operationId: 'op-1', result: {revision: NEXT}, notificationDelivery: 'unavailable'}); + const [path, init] = f.transport.mock.calls[1]; + expect(path).toBe(f.target.applicationBase + 'api/v1/' + paths[index]); expect(init.method).toBe('PUT'); + const body = administrationJSON(init.body) as Record; + expect(responseRevision(body.expected_revision)).toBe(REV); + if (index === 3) expect(body.alias).toBe(' Literal '); + expect(new Headers(init.headers).get('Idempotency-Key')).toBe('original-key'); + expect(new Headers(init.headers).get('X-CSRF-Token')).toBe('c'.repeat(64)); + expect(await f.server.original({...input, operationId: 'op-1'}, context).result).toMatchObject({kind: 'receipt', eventDelivery: 'pending'}); + expect(f.transport.mock.calls[2][0]).toBe(f.target.applicationBase + 'api/v1/administration/requests/' + command.action + '/original-key'); + expect(f.transport.mock.calls[2][1].method).toBe('GET'); expect(f.transport).toHaveBeenCalledTimes(3); + }); +} + +test('the roster budget accepts all 256 valid maximal UTF-8 labels without using or changing the 64 KiB journal budget', async () => { + const f = fixture(), data = roster(); + const ordinary = ['household.read','members.read','preferences.read','preferences.write','operations.read','tasks.read','tasks.claim','tasks.manage', + 'tasks.record_for_others','tasks.material_use','tasks.create','tasks.complete','tasks.approve','shopping.request','shopping.manage','inventory.manage']; + data.members = Array.from({length: 256}, (_v, index) => { + const id = 'm' + String(index).padStart(127, '0'); + return {member: {...member(), member_id: id, ha_user_id: index === 0 ? context.subject : index.toString(16).padStart(32, '0'), exceptions: ordinary.map(permission => ({permission, allow: true}))}, + presentation: {member_id: id, alias: '😀'.repeat(80), revision: '$revision'}, metadata: {state: 'available', name: '😀'.repeat(256), avatar_state: 'unavailable'}}; + }); + const bytes = new TextEncoder().encode(encode(data)).byteLength; + expect(bytes).toBeGreaterThan(65536); expect(bytes).toBeLessThan(ROSTER_MAX_BYTES); + f.transport.mockResolvedValueOnce(reply(data)); + expect((await f.server.roster(context).result).members).toHaveLength(256); +}); + +test.each([401,403,409,422,503])('known mutation rejection %s keeps only its safe reason/correlation', async status => { + const f = fixture(); f.transport.mockResolvedValueOnce(reply({csrf_token: 'c'.repeat(64)})).mockResolvedValueOnce(blocked(status)); + const result = await f.server.send({key: 'key', command: administrationCommand(commands[4])}, context); + expect(result).toMatchObject({kind: 'rejected', correlationId: 'cor_safe'}); expect(JSON.stringify(result)).not.toContain('Private detail'); +}); + +test.each(['lost', 'malformed', 'mismatched_revision', 'mismatched_owner', 'unexpected_status'])( + 'a %s mutation response never triggers an automatic resend', async failure => { + const f = fixture(); f.transport.mockResolvedValueOnce(reply({csrf_token: 'c'.repeat(64)})); + if (failure === 'lost') f.transport.mockRejectedValueOnce(new Error('private error')); + else f.transport.mockResolvedValueOnce(reply(failure === 'malformed' ? {} : {...receipt(3), result: {...results[3], + ...(failure === 'mismatched_revision' ? {revision: '$revision'} : {}), ...(failure === 'mismatched_owner' ? {member_id: 'other'} : {})}}, failure === 'unexpected_status' ? 201 : 200)); + expect(await f.server.send({key: 'key', command: administrationCommand(commands[3])}, context)).toEqual({kind: 'uncertain'}); + expect(f.transport).toHaveBeenCalledTimes(2); + }); + +test('unsupported decoder/transport budget choices fail before data or a request can be accepted', async () => { + expect(() => administrationJSON('{}', 0 as never)).toThrow('invalid_response'); + const f = fixture(), network = new BoundedHTTP(f.target, f.network); + await expect(network.get('session', context, new AbortController().signal, 'unknown' as never)).rejects.toThrow('invalid_response'); + expect(f.transport).not.toHaveBeenCalled(); +}); + +test.each(['absent','denied','unavailable'])('masked roster preserves a truthful %s label without HA linkage or invented data', async state => { + const f = fixture(); f.transport.mockResolvedValueOnce(reply({directory_state: 'unavailable', members: [{member: {...member(), ha_user_id: null, exceptions: null}, + presentation: presentation(), metadata: {state, name: null, avatar_state: 'unavailable'}}]})); + expect((await f.server.roster(context).result).members[0]).toMatchObject({member: {haUserId: null, exceptions: null}, metadata: {state, name: null}}); +}); + +test.each(['available','denied','unavailable'])('directory %s with no choices remains distinct from another state', async state => { + const f = fixture(); f.transport.mockResolvedValueOnce(reply({state, profiles: []})); + expect(await f.server.directory(context).result).toEqual({state, profiles: []}); +}); + +test('pending and unconfigured authorization preserve actual membership rather than fabricating active grants', async () => { + const f = fixture(); + f.transport.mockResolvedValueOnce(reply({state: 'unconfigured', setup_allowed: true, enrollment_allowed: false, + permission_scope: 'household_administration', permissions: [], message: 'Setup.', action_required: 'Review.'})); + expect(await f.server.authorization(context).result).toMatchObject({member: null, policyRevision: null, effectiveRole: null}); + f.transport.mockResolvedValueOnce(reply({...access(), state: 'pending', member: {...member(), status: 'pending', ha_user_id: null, exceptions: null}, + policy_revision: null, effective_role: null, permissions: []})); + expect(await f.server.authorization(context).result).toMatchObject({member: {status: 'pending'}, effectiveRole: null}); +}); + +test('targeted policy, ordinary profile decisions and browser-following preferences retain immutable exact facts', async () => { + const f = fixture(); + f.transport.mockResolvedValueOnce(reply({...policy(), mapping: {mode: 'setup_administrator', target_ha_user_id: context.subject}, + adjustments: [{role: 'adult', permission: 'tasks.complete', allow: false}]})); + const result = await f.server.policy(context).result; + expect(result).toMatchObject({targetHAUserId: context.subject, mappingMode: 'setup_administrator'}); + expect(Object.isFrozen(result.adjustments[0])).toBe(true); + f.transport.mockResolvedValueOnce(reply({...preferences(), locale: null})); + expect((await f.server.preferences(context).result).locale).toBeNull(); +}); + +type Projection = 'capabilities' | 'authorization' | 'policy' | 'roster' | 'directory' | 'preferences'; +const malformed: readonly [Projection, unknown][] = [ + ['capabilities', {...capabilities(), state: 'unsupported'}], ['capabilities', {...capabilities(), contract_version: 3}], + ['capabilities', {...capabilities(), contract_version: 1}], + ...[null, 7, '../foreign', ''].map(household_id => ['capabilities', {...capabilities(), household_id}] as [Projection, unknown]), + ['capabilities', {...capabilities(), exact_revisions: 'float'}], ['capabilities', {...capabilities(), actions: ['household.update']}], + ['capabilities', {...capabilities(), actions: Array(5).fill('household.update')}], ['capabilities', {...capabilities(), actions: [...commands.slice(0,4).map(c => c.action),'unknown.action']}], + ...[{namespace: 'entry'}, {version: 2}, {maximum_records: 9}, {maximum_bytes: 65537}, {lifetime_ms: 7200000}].map(change => + ['capabilities', {...capabilities(), journal: {...capabilities().journal, ...change}}] as [Projection, unknown]), + ['roster', {members: {}, directory_state: 'available'}], ['roster', {...roster(), members: Array(257).fill(roster().members[0])}], + ['roster', {...roster(), members: Array(2).fill(roster().members[0])}], + ['roster', {directory_state: 'available', members: [roster().members[0], {...roster().members[0], member: {...member(), member_id: 'other'}, presentation: {...presentation(), member_id: 'other'}}]}], + ['roster', {...roster(), members: [{...roster().members[0], presentation: {...presentation(), member_id: 'other'}}]}], + ...[{state: 'future'}, {state: 'available', name: null}, {state: 'denied', name: 'Unknown'}, {name: ''}, {name: 'x'.repeat(257)}, + {name: 'bad\nname'}, {name: '\ud800'}, {avatar_state: 'available'}, {avatar_url: 'https://external.invalid/image'}].map(change => + ['roster', {...roster(), members: [{...roster().members[0], metadata: {...roster().members[0].metadata, ...change}}]}] as [Projection, unknown]), + ['policy', {...policy(), mapping: {mode: 'selected_ha_user', target_ha_user_id: null}}], + ['policy', {...policy(), mapping: {mode: 'all_ha_administrators', target_ha_user_id: context.subject}}], + ['policy', {...policy(), adjustments: [{role: 'adult', permission: 'policy.manage', allow: true}]}], + ['policy', {...policy(), adjustments: [{role: 'future', permission: 'tasks.read', allow: true}]}], + ['policy', {...policy(), adjustments: Array(2).fill({role: 'adult', permission: 'tasks.read', allow: true})}], + ['directory', {state: 'denied', profiles: [{ha_user_id: context.subject, name: 'Denied'}]}], + ['directory', {state: 'available', profiles: Array(257).fill({ha_user_id: context.subject, name: 'Overflow'})}], + ['directory', {state: 'available', profiles: Array(2).fill({ha_user_id: context.subject, name: 'Duplicate'})}], + ['directory', {state: 'available', profiles: [{ha_user_id: 'other-user', name: 'Unverified'}]}], + ['preferences', {...preferences(), member_id: '../other'}], ['preferences', {...preferences(), notification_delivery: 'delivered'}], + ['preferences', {...preferences(), locale: 'fr'}], ['preferences', {...preferences(), routine_participation_summaries: 'false'}], + ['preferences', {...preferences(), actor: 'other'}], +]; +test.each(malformed)('malformed %s projection case %# cannot become usable facts', async (method, raw) => { + const f = fixture(); f.transport.mockResolvedValueOnce(reply(raw)); + await expect(f.server[method](context).result).rejects.toThrow('invalid_response'); +}); + +test.each([{alias: ' Unnormalized '}, {alias: '\nControl'}, {member_id: 'other'}])('a changed presentation %j cannot stand for the requested target', async change => { + const f = fixture(); f.transport.mockResolvedValueOnce(reply({...presentation(), ...change})); + await expect(f.server.presentation('member-1', context).result).rejects.toThrow('invalid_response'); +}); + +test.each([{key: 'bad/key'}, {command: {action: 'unknown', payload: {}}}, {extra: 'credential'}])('invalid original request %j fails before CSRF or a write', async patch => { + const f = fixture(); const input = {key: 'key', command: administrationCommand(commands[0]), ...patch}; + expect(await f.server.send(input as never, context)).toMatchObject({kind: 'rejected', reason: 'invalid_input'}); + expect(f.transport).not.toHaveBeenCalled(); +}); + +test.each(['denied','malformed','transport'] as const)('CSRF %s cannot become a mutation or expose raw failure data', async failure => { + const f = fixture(); + if (failure === 'transport') f.transport.mockRejectedValueOnce(new Error('private transport data')); + else f.transport.mockResolvedValueOnce(failure === 'denied' ? blocked(403) : reply({csrf_token: 'wrong'})); + const result = await f.server.send({key: 'key', command: administrationCommand(commands[4])}, context); + expect(result).toMatchObject({kind: 'rejected'}); expect(JSON.stringify(result)).not.toContain('private'); expect(f.transport).toHaveBeenCalledTimes(1); +}); + +test.each(['csrf','put'] as const)('a timeout during %s retires the request without an automatic mutation retry', async phase => { + vi.useFakeTimers(); const f = fixture(); + if (phase === 'put') f.transport.mockResolvedValueOnce(reply({csrf_token: 'c'.repeat(64)})); + f.transport.mockImplementation(() => new Promise(() => {})); + const result = f.server.send({key: 'original', command: administrationCommand(commands[4])}, context); + await vi.advanceTimersByTimeAsync(HTTP_TIMEOUT_MS); + expect(await result).toMatchObject({kind: phase === 'put' ? 'uncertain' : 'rejected'}); + expect(f.transport).toHaveBeenCalledTimes(phase === 'put' ? 2 : 1); +}); + +test('historical original processing does not become current effect or notification delivery', async () => { + const f = fixture(); f.transport.mockResolvedValueOnce(reply({...receipt(4, true), state: 'historical', archived_by: 'restore-1', event_delivery: 'suppressed'})); + expect(await f.server.original({key: 'original-key', command: administrationCommand(commands[4]), operationId: null}, context).result) + .toMatchObject({stage: 'historical', archivedBy: 'restore-1', eventDelivery: 'suppressed', notificationDelivery: 'unavailable'}); +}); + +test.each([{action: 'member.update'}, {command_key: 'other'}, {operation_id: 'other'}, {state: 'historical'}, {event_delivery: 'suppressed'}, + {notification_delivery: 'delivered'}, {result: {...results[4], locale: 'en'}}])('an unbound original response %j cannot replace the captured intent', async patch => { + const f = fixture(); f.transport.mockResolvedValueOnce(reply({...receipt(4, true), ...patch})); + await expect(f.server.original({key: 'original-key', command: administrationCommand(commands[4]), operationId: 'op-1'}, context).result).rejects.toThrow('invalid_response'); +}); + +test('invalid original lookup and oversized roster fail without producing a partial result', async () => { + const f = fixture(); + await expect(f.server.original({key: 'bad/key', command: administrationCommand(commands[0]), operationId: null}, context).result).rejects.toThrow('invalid_response'); + expect(f.transport).not.toHaveBeenCalled(); + f.transport.mockResolvedValueOnce(new Response(' '.repeat(ROSTER_MAX_BYTES + 1), {headers: {'Content-Type': 'application/json', 'Cache-Control': 'no-store'}})); + await expect(f.server.roster(context).result).rejects.toThrow('invalid_response'); +}); + +test('deadline at each CSRF continuation never invokes a mutation with an aborted signal', async () => { + vi.useFakeTimers({toFake: ['setTimeout','clearTimeout']}); + for (let delay = 0; delay < 20; delay++) { + const f = fixture(); + f.transport.mockImplementationOnce(async () => { + const stream = new ReadableStream({start(controller) { + controller.enqueue(new TextEncoder().encode(encode({csrf_token: 'c'.repeat(64)}))); controller.close(); + const later = (remaining: number) => {if (remaining === 0) vi.advanceTimersByTime(HTTP_TIMEOUT_MS); else queueMicrotask(() => later(remaining - 1));}; + queueMicrotask(() => later(delay)); + }}); + return new Response(stream, {headers: {'Content-Type': 'application/json', 'Cache-Control': 'no-store'}}); + }); + f.transport.mockImplementationOnce(async (_path, init) => { + expect(init.signal!.aborted).toBe(false); return reply(receipt(4)); + }); + await f.server.send({key: 'original', command: administrationCommand(commands[4])}, context); + for (let index = 0; index < 20; index++) await Promise.resolve(); + expect(vi.getTimerCount()).toBe(0); + expect(f.transport.mock.calls.filter(([, init]) => init.method === 'PUT').length).toBeLessThanOrEqual(1); + } +}); + +test.each(commands)('operation-ID recovery for $action retains its captured intent under the existing protected GET contract', async raw => { + const f = fixture(), index = commands.indexOf(raw), data = receipt(index); + const {notification_delivery: _notification, ...legacy} = data; + f.transport.mockResolvedValueOnce(reply({...legacy, delivery: 'delivered'})); + const original = {key: 'original-key', command: administrationCommand(raw), operationId: 'op-1'}; + expect(await f.server.operation(original, context).result).toMatchObject({kind: 'receipt', operationId: 'op-1', action: raw.action, + eventDelivery: 'delivered', notificationDelivery: 'unavailable', result: {revision: NEXT}}); + expect(f.transport.mock.calls[0][0]).toBe('/api/v1/operations/op-1'); + const init = f.transport.mock.calls[0][1]; expect(init.method).toBe('GET'); expect(init.body).toBeNull(); + expect(new Headers(init.headers).has('X-CSRF-Token')).toBe(false); +}); + +test('operation-ID lookup requires a captured valid ID and rejects a replacement response', async () => { + const f = fixture(), command = administrationCommand(commands[4]); + await expect(f.server.operation({key: 'original-key', command, operationId: null}, context).result).rejects.toThrow('invalid_response'); + expect(f.transport).not.toHaveBeenCalled(); + const {notification_delivery: _notification, ...legacy} = receipt(4); + f.transport.mockResolvedValueOnce(reply({...legacy, operation_id: 'replacement', delivery: 'delivered'})); + await expect(f.server.operation({key: 'original-key', command, operationId: 'op-1'}, context).result).rejects.toThrow('invalid_response'); +}); diff --git a/frontend/tests/administration-journal.test.ts b/frontend/tests/administration-journal.test.ts new file mode 100644 index 0000000..6f0df70 --- /dev/null +++ b/frontend/tests/administration-journal.test.ts @@ -0,0 +1,190 @@ +import {expect, test} from 'vitest'; +import {administrationCommand} from '../src/domain/administration-commands'; +import {emptyAdministrationJournal, inspectAdministrationJournal, administrationView, + prepareAdministration, recordAdministration, forgetAdministration} from '../src/domain/administration-journal'; +import {JournalError, JOURNAL_LIFETIME} from '../src/domain/journal'; +import type {JournalContext} from '../src/domain/journal'; +import {administrationCommandBody, administrationJSON, responseRevision} from '../src/adapters/administration-json'; + +const context: JournalContext = {installationId: '01234567-89ab-cdef-0123-456789abcdef', + channel: 'edge', subject: 'a'.repeat(32)}; +const other = {...context, subject: 'b'.repeat(32)}; +const revision = '9007199254740993'; +const examples = [ + {action: 'household.update', payload: {expected_revision: revision, name: ' Home ', time_zone: 'UTC'}}, + {action: 'member.update', memberId: 'member-1', payload: {expected_revision: revision, + role: 'restricted', status: 'active', exceptions: [{permission: 'tasks.complete', allow: true}]}}, + {action: 'policy.update', payload: {expected_revision: revision, mapping_mode: 'selected_ha_user', + target_ha_user_id: 'b'.repeat(32), adjustments: [{role: 'adult', permission: 'inventory.manage', allow: false}]}}, + {action: 'member.presentation.update', memberId: 'member-1', payload: {expected_revision: revision, alias: ' Literal '}}, + {action: 'member.preferences.update', payload: {expected_revision: revision, locale: 'es', routine_participation_summaries: false}}, +]; +const preferences = () => administrationCommand(examples[4]); +const started = () => prepareAdministration(emptyAdministrationJournal(1000), context, 'key', preferences(), 1000); +const raw = () => JSON.parse(JSON.stringify(started())); + +test.each(examples)('closed command retains the original values for $action', example => { + const command = administrationCommand(example); + expect(command).toEqual(example); + expect(Object.isFrozen(command)).toBe(true); + expect(Object.isFrozen(command.payload)).toBe(true); + expect(command.payload.expected_revision).toBe(revision); +}); + +test.each(examples)('wire encoder preserves the exact journal intent for $action', example => { + const command = administrationCommand(example); + const body = administrationCommandBody(command); + const decoded = administrationJSON(body) as Record; + expect(responseRevision(decoded.expected_revision)).toBe(revision); + const {expected_revision: _revision, ...values} = decoded; + const {expected_revision: _original, ...original} = example.payload; + expect(values).toEqual(original); + expect(Object.hasOwn(decoded, 'memberId')).toBe(false); + expect(Object.hasOwn(decoded, 'action')).toBe(false); +}); + +test('command copies nested decisions and never normalizes the submitted intent', () => { + const input = structuredClone(examples[1]); + const command = administrationCommand(input); + (input.payload as {exceptions: {allow: boolean}[]}).exceptions[0].allow = false; + expect(command).toEqual(examples[1]); + if (command.action !== 'member.update') throw new Error('Expected the original member command'); + expect(Object.isFrozen(command.payload.exceptions)).toBe(true); + expect(administrationCommand(examples[0]).payload).toHaveProperty('name', ' Home '); + expect(administrationCommand(examples[3]).payload).toHaveProperty('alias', ' Literal '); +}); + +test.each([null, [], 1, {}, {action: 'setup', payload: {}}, {action: 'enroll', payload: {}}, + {...examples[4], memberId: 'other'}, {...examples[4], csrf: 'secret'}, + {...examples[4], payload: {...examples[4].payload, ha_user_id: 'a'.repeat(32)}}, + {...examples[4], payload: {...examples[4].payload, expected_revision: 1}}, + {...examples[4], payload: {...examples[4].payload, expected_revision: '01'}}, + {...examples[4], payload: {...examples[4].payload, locale: 'future'}}, + {...examples[4], payload: {...examples[4].payload, routine_participation_summaries: 'true'}}, + {...examples[3], memberId: 'invalid/id'}, {...examples[3], payload: {expected_revision: '1', alias: '\nName'}}, + {...examples[0], payload: {expected_revision: '1', name: ' ', time_zone: 'UTC'}}, + {...examples[0], payload: {expected_revision: '1', name: 'Home', time_zone: ''}}, + {...examples[0], payload: {expected_revision: '1', name: 'x'.repeat(161), time_zone: 'UTC'}}, + {...examples[0], payload: {expected_revision: '1', name: '\ud800', time_zone: 'UTC'}}, + {...examples[1], payload: {...examples[1].payload, role: 'future'}}, + {...examples[1], payload: {...examples[1].payload, status: 'future'}}, + {...examples[1], payload: {...examples[1].payload, exceptions: 'wrong'}}, + {...examples[1], payload: {...examples[1].payload, exceptions: Array(257).fill({permission: 'tasks.read', allow: true})}}, + {...examples[1], payload: {...examples[1].payload, exceptions: [{permission: 'tasks.configure', allow: true}]}}, + {...examples[1], payload: {...examples[1].payload, exceptions: [{permission: 'future.read', allow: true}]}}, + {...examples[1], payload: {...examples[1].payload, exceptions: [{permission: 'tasks.read', allow: 1}]}}, + {...examples[1], payload: {...examples[1].payload, exceptions: Array(2).fill({permission: 'tasks.read', allow: true})}}, + {...examples[2], payload: {...examples[2].payload, adjustments: Array(2).fill({role: 'adult', permission: 'tasks.read', allow: true})}}, + {...examples[2], payload: {...examples[2].payload, adjustments: [{role: 'future', permission: 'tasks.read', allow: true}]}}, + {...examples[2], payload: {...examples[2].payload, mapping_mode: 'future'}}, + {...examples[2], payload: {...examples[2].payload, mapping_mode: 'all_ha_administrators'}}, + {...examples[2], payload: {...examples[2].payload, target_ha_user_id: null}}, + {...examples[2], payload: {...examples[2].payload, target_ha_user_id: 'not-a-ha-id'}}, +])('unsupported command case %# cannot enter recovery', value => { + expect(() => administrationCommand(value)).toThrow('invalid_command'); +}); + +test('all mapping modes, nullable alias and browser-following locale retain their exact types', () => { + for (const mapping_mode of ['all_ha_administrators', 'setup_administrator']) { + const value = {...examples[2], payload: {...examples[2].payload, mapping_mode, + target_ha_user_id: mapping_mode === 'all_ha_administrators' ? null : 'b'.repeat(32)}}; + expect(administrationCommand(value)).toEqual(value); + } + expect(administrationCommand({...examples[3], payload: {expected_revision: '1', alias: null}}).payload).toHaveProperty('alias', null); + expect(administrationCommand({...examples[4], payload: {expected_revision: '1', locale: null, routine_participation_summaries: true}}).payload).toHaveProperty('locale', null); +}); + +test('household name validation follows backend Unicode whitespace while retaining the original text', () => { + const value = (name: string) => ({...examples[0], payload: {expected_revision: '1', name, time_zone: 'UTC'}}); + expect(() => administrationCommand(value('\u0085'))).toThrow('invalid_command'); + expect(administrationCommand(value('\uFEFF'))).toEqual(value('\uFEFF')); + expect(administrationCommand(value('\u0085Home\u0085'))).toEqual(value('\u0085Home\u0085')); +}); + +test('separate namespace, immutable context, original high revision and one-hour deadline survive inspection', () => { + const original = started(); + const decoded = inspectAdministrationJournal(JSON.parse(JSON.stringify(original)), 1100); + expect(decoded.namespace).toBe('administration'); + expect(decoded.version).toBe(1); + expect(decoded.records[0]).toMatchObject({context, key: 'key', ...examples[4], + createdAt: 1000, expiresAt: 1000 + JOURNAL_LIFETIME, stage: 'prepared', operationId: null}); + for (const value of [decoded, decoded.records, decoded.records[0], decoded.records[0].context, decoded.records[0].payload]) + expect(Object.isFrozen(value)).toBe(true); + expect(inspectAdministrationJournal(decoded, 3601000).records).toEqual([]); +}); + +test('all five original actions retain their supported target and independent payload through persistence', () => { + let journal = emptyAdministrationJournal(1000); + for (const [index, example] of examples.entries()) + journal = prepareAdministration(journal, context, 'key' + index, administrationCommand(example), 1000); + const decoded = inspectAdministrationJournal(JSON.parse(JSON.stringify(journal)), 1100); + expect(decoded.records.map(record => ({action: record.action, payload: record.payload, + ...('memberId' in record ? {memberId: record.memberId} : {})}))).toEqual(examples); +}); + +test('another subject, installation or channel cannot view, update or forget the original', () => { + for (const changed of [other, {...context, channel: 'stable' as const}, + {...context, installationId: '11111111-1111-1111-1111-111111111111'}]) { + expect(administrationView(started(), changed, 1000).records).toEqual([]); + expect(() => recordAdministration(started(), changed, 'key', {stage: 'committed', operationId: 'op'}, 1000)).toThrow('request_not_found'); + expect(() => forgetAdministration(started(), changed, 'key', 1000)).toThrow('request_not_found'); + } +}); + +test('switching contexts retains eligible originals while the eight-record budget covers the entire namespace', () => { + let journal = prepareAdministration(started(), other, 'key', preferences(), 1000); + expect(administrationView(journal, context, 1100).records).toHaveLength(1); + expect(administrationView(journal, other, 1100).records).toHaveLength(1); + for (let index = 2; index < 8; index++) journal = prepareAdministration(journal, other, 'key' + index, preferences(), 1100); + expect(() => prepareAdministration(journal, context, 'ninth', preferences(), 1100)).toThrow('journal_capacity'); + expect(() => prepareAdministration(started(), context, 'key', preferences(), 1100)).toThrow('duplicate_request'); + expect(prepareAdministration(journal, context, 'after-expiry', preferences(), 3601100).records).toHaveLength(1); +}); + +test.each(['uncertain', 'rejected', 'committed', 'historical'] as const)('original %s outcome never changes intent or retention', stage => { + const operationId = stage === 'committed' || stage === 'historical' ? 'op' : null; + const current = recordAdministration(started(), context, 'key', {stage, operationId}, 1100); + expect(current.records[0]).toEqual({...started().records[0], stage, operationId}); + if (stage === 'uncertain') expect(() => forgetAdministration(current, context, 'key', 1200)).toThrow('unresolved_request'); + else expect(forgetAdministration(current, context, 'key', 1200).records).toEqual([]); +}); + +test('known operation cannot change or be forgotten as an uncertain request; other contexts survive dismissal', () => { + const initial = prepareAdministration(started(), other, 'key', preferences(), 1000); + const saved = recordAdministration(initial, context, 'key', {stage: 'committed', operationId: 'op'}, 1000); + const historical = recordAdministration(saved, context, 'key', {stage: 'historical', operationId: 'op'}, 1100); + expect(historical.records[1]).toEqual(initial.records[1]); + for (const outcome of [{stage: 'committed', operationId: 'replacement'}, {stage: 'uncertain', operationId: null}]) + expect(() => recordAdministration(saved, context, 'key', outcome as never, 1100)).toThrow('original_operation_changed'); + expect(forgetAdministration(historical, context, 'key', 1200).records).toEqual([initial.records[1]]); + expect(() => forgetAdministration(started(), context, 'key', 1100)).toThrow('unresolved_request'); +}); + +test.each([null, [], {}, {...raw(), namespace: 'entry'}, {...raw(), version: 2}, + {...raw(), token: 'secret'}, {...raw(), records: {}}, {...raw(), records: Array(9).fill(raw().records[0])}])( + 'forged envelope case %# fails closed', value => expect(() => inspectAdministrationJournal(value, 1100)).toThrow(JournalError)); + +test.each([{key: ''}, {stage: 'future'}, {stage: 'prepared', operationId: 'op'}, + {stage: 'committed'}, {stage: 'committed', operationId: 'invalid/id'}, + {payload: {}}, {cookie: 'secret'}, {createdAt: 1001}, {expiresAt: 3601001}, + {context: {...context, subject: 'invalid'}}, {context: {...context, channel: 'nightly'}}, + {context: {...context, installationId: 'wrong'}}, {context: {...context, token: 'secret'}}])( + 'forged record case %# fails closed', change => { + const value = raw(); Object.assign(value.records[0], change); + expect(() => inspectAdministrationJournal(value, 1100)).toThrow(JournalError); + }); + +test.each([NaN, Infinity, -1, .5, Number.MAX_SAFE_INTEGER + 1, '1000', null])('clock case %# never extends retention', value => { + expect(() => emptyAdministrationJournal(value as never)).toThrow('invalid_journal_time'); +}); + +test('backward time, overflow, duplicates and invalid outcomes preserve the original journal', () => { + expect(() => inspectAdministrationJournal(started(), 999)).toThrow('invalid_journal_time'); + expect(() => prepareAdministration(emptyAdministrationJournal(Number.MAX_SAFE_INTEGER), context, 'key', preferences(), Number.MAX_SAFE_INTEGER)).toThrow('invalid_journal_time'); + const duplicate = raw(); duplicate.records.push(duplicate.records[0]); + expect(() => inspectAdministrationJournal(duplicate, 1100)).toThrow('duplicate_request'); + for (const outcome of [{stage: 'prepared', operationId: null}, {stage: 'future', operationId: null}, + {stage: 'rejected', operationId: 'op'}, {stage: 'committed', operationId: null}, + {stage: 'committed', operationId: 'op', csrf: 'secret'}]) + expect(() => recordAdministration(started(), context, 'key', outcome as never, 1100)).toThrow(JournalError); +}); diff --git a/frontend/tests/administration-session-journal.test.ts b/frontend/tests/administration-session-journal.test.ts new file mode 100644 index 0000000..160b9df --- /dev/null +++ b/frontend/tests/administration-session-journal.test.ts @@ -0,0 +1,144 @@ +import {afterEach, expect, test, vi} from 'vitest'; +import {AdministrationSessionJournal, ADMINISTRATION_JOURNAL_KEY} from '../src/adapters/administration-journal'; +import {ENTRY_JOURNAL_KEY} from '../src/adapters/session-journal'; +import {administrationCommand} from '../src/domain/administration-commands'; +import {JournalStorageError} from '../src/application/ports'; +import type {JournalContext} from '../src/domain/journal'; + +const context: JournalContext = {installationId: '01234567-89ab-cdef-0123-456789abcdef', + channel: 'edge', subject: 'a'.repeat(32)}; +const other = {...context, subject: 'b'.repeat(32)}; +const command = administrationCommand({action: 'member.preferences.update', payload: { + expected_revision: '9007199254740993', locale: 'es', routine_participation_summaries: false}}); +function fixture() { + const values = new Map([[ENTRY_JOURNAL_KEY, 'entry original'], ['unrelated', 'keep']]); + 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()}; + return {values, storage, journal: new AdministrationSessionJournal(storage)}; +} +afterEach(() => sessionStorage.removeItem(ADMINISTRATION_JOURNAL_KEY)); + +test('the physical administration namespace is separate; original revisions remain decimal text', () => { + const f = fixture(); + f.journal.open(context, 1000); + const original = f.journal.prepare('key', command, 1000).records[0]; + const encoded = f.values.get(ADMINISTRATION_JOURNAL_KEY)!; + expect(encoded).toContain('"expected_revision":"9007199254740993"'); + expect(JSON.parse(encoded).namespace).toBe('administration'); + expect(f.values.get(ENTRY_JOURNAL_KEY)).toBe('entry original'); + expect(f.values.get('unrelated')).toBe('keep'); + expect(f.storage.getItem.mock.calls.every(([key]) => key === ADMINISTRATION_JOURNAL_KEY)).toBe(true); + f.journal.detach(); + expect(() => f.journal.prepare('replacement', command, 1001)).toThrow('journal_not_open'); + expect(f.journal.open(context, 1100).records).toEqual([original]); + expect(f.journal.record('key', {stage: 'uncertain', operationId: null}, 1200).records[0]).toEqual({...original, stage: 'uncertain'}); + f.journal.record('key', {stage: 'historical', operationId: 'op'}, 1300); + expect(f.journal.forget('key', 1400).records).toEqual([]); + expect(f.storage.removeItem).not.toHaveBeenCalled(); +}); + +test('account/channel/installation switches hide old private requests without deleting them', () => { + const f = fixture(); f.journal.open(context, 1000); + const original = f.journal.prepare('key', command, 1000).records[0]; + for (const next of [other, {...context, channel: 'stable' as const}, + {...context, installationId: '11111111-1111-1111-1111-111111111111'}]) { + const view = f.journal.open(next, 1100); + expect(view.records).toEqual([]); + expect(JSON.stringify(view)).not.toContain('routine_participation_summaries'); + expect(() => f.journal.record('key', {stage: 'committed', operationId: 'op'}, 1100)).toThrow('request_not_found'); + } + expect(f.journal.open(context, 1200).records).toEqual([original]); + f.journal.open(other, 1200); f.journal.prepare('key', command, 1200); + f.journal.record('key', {stage: 'rejected', operationId: null}, 1300); f.journal.forget('key', 1400); + expect(f.journal.open(context, 1500).records).toEqual([original]); +}); + +test.each(['getItem', 'setItem'] as const)('provider %s failures drop memory and expose no original exception', method => { + const f = fixture(); f.journal.open(context, 1000); + f.storage[method].mockImplementationOnce(() => {throw new Error('provider secret');}); + expect(() => f.journal.prepare('key', command, 1100)).toThrow('storage_unavailable'); + expect(() => f.journal.prepare('other', command, 1200)).toThrow('journal_not_open'); +}); + +test('missing storage cannot open a handle or authorize preparing a request', () => { + const journal = new AdministrationSessionJournal(undefined); + expect(() => journal.open(context, 1000)).toThrow(JournalStorageError); + expect(() => journal.prepare('key', command, 1000)).toThrow('journal_not_open'); +}); + +test.each([undefined, 4, {}])('malformed provider return case %# fails without writing', raw => { + const f = fixture(); f.storage.getItem.mockReturnValueOnce(raw as never); + expect(() => f.journal.open(context, 1000)).toThrow('storage_unavailable'); + expect(f.storage.setItem).not.toHaveBeenCalled(); +}); + +test.each(['not JSON', '{}', '{"version":1,"version":1}', ' '.repeat(65536)])( + 'malformed/noncanonical input case %# is preserved for explicit recovery', raw => { + const f = fixture(); f.values.set(ADMINISTRATION_JOURNAL_KEY, raw); + expect(() => f.journal.open(context, 1000)).toThrow('invalid_journal'); + expect(f.values.get(ADMINISTRATION_JOURNAL_KEY)).toBe(raw); + expect(f.storage.setItem).not.toHaveBeenCalled(); + }); + +test.each([' '.repeat(65537), '"' + 'é'.repeat(40000) + '"'])( + 'byte bound case %# is checked before decoding or altering stored work', raw => { + const f = fixture(); f.values.set(ADMINISTRATION_JOURNAL_KEY, raw); + expect(() => f.journal.open(context, 1000)).toThrow('journal_too_large'); + expect(f.storage.setItem).not.toHaveBeenCalled(); + }); + +test('unknown fields and numeric revision coercion are never accepted as stored intent', () => { + for (const edit of [(raw: Record) => {raw.csrf = 'secret';}, + (raw: Record) => {((raw.records as {payload: Record}[])[0].payload).expected_revision = 9007199254740992;}]) { + const f = fixture(); f.journal.open(context, 1000); f.journal.prepare('key', command, 1000); + const raw = JSON.parse(f.values.get(ADMINISTRATION_JOURNAL_KEY)!); edit(raw); + const encoded = JSON.stringify(raw); f.values.set(ADMINISTRATION_JOURNAL_KEY, encoded); + expect(() => f.journal.open(context, 1100)).toThrow('invalid_journal'); + expect(f.values.get(ADMINISTRATION_JOURNAL_KEY)).toBe(encoded); + } +}); + +test('a competing storage write cannot be overwritten or used through a stale in-memory handle', () => { + const f = fixture(); f.journal.open(context, 1000); + f.values.set(ADMINISTRATION_JOURNAL_KEY, 'competing original'); + expect(() => f.journal.prepare('key', command, 1100)).toThrow('storage_changed'); + expect(f.values.get(ADMINISTRATION_JOURNAL_KEY)).toBe('competing original'); + expect(() => f.journal.prepare('again', command, 1200)).toThrow('journal_not_open'); +}); + +test('a write without matching readback is not treated as confirmed persistence', () => { + const f = fixture(); f.storage.setItem.mockImplementationOnce(() => {}); + expect(() => f.journal.open(context, 1000)).toThrow('storage_unconfirmed'); + expect(() => f.journal.prepare('key', command, 1000)).toThrow('journal_not_open'); +}); + +test('a lost persistence write after a server receipt preserves the old original and the caller receipt', () => { + const f = fixture(); f.journal.open(context, 1000); + const original = f.journal.prepare('key', command, 1000).records[0]; + const receipt = Object.freeze({stage: 'committed' as const, operationId: 'original-op'}); + f.storage.setItem.mockImplementationOnce(() => {throw new Error('storage full');}); + expect(() => f.journal.record('key', receipt, 1100)).toThrow('storage_unavailable'); + expect(receipt.operationId).toBe('original-op'); + expect(f.journal.open(context, 1200).records).toEqual([original]); +}); + +test('invalid context and backward time cannot alter storage; only expiry removes old unresolved work', () => { + const f = fixture(); + expect(() => f.journal.open({...context, subject: 'wrong'}, 1000)).toThrow('invalid_journal'); + expect(f.storage.setItem).not.toHaveBeenCalled(); + f.journal.open(context, 1000); f.journal.prepare('key', command, 1000); + const stored = f.values.get(ADMINISTRATION_JOURNAL_KEY); + expect(() => f.journal.open(context, 999)).toThrow('invalid_journal_time'); + expect(f.values.get(ADMINISTRATION_JOURNAL_KEY)).toBe(stored); + expect(f.journal.open(context, 3600999).records).toHaveLength(1); + expect(f.journal.open(context, 3601000).records).toEqual([]); + expect(f.values.get(ENTRY_JOURNAL_KEY)).toBe('entry original'); +}); + +test('real isolated browser sessionStorage supports a cold administration journal without a network effect', () => { + const journal = new AdministrationSessionJournal(sessionStorage); + journal.open(context, 1000); journal.prepare('original', command, 1000); journal.detach(); + const next = new AdministrationSessionJournal(sessionStorage); + expect(next.open(context, 1100).records[0].payload.expected_revision).toBe('9007199254740993'); + expect(() => next.forget('original', 1200)).toThrow('unresolved_request'); +}); diff --git a/frontend/tests/administration.test.ts b/frontend/tests/administration.test.ts new file mode 100644 index 0000000..d53ef52 --- /dev/null +++ b/frontend/tests/administration.test.ts @@ -0,0 +1,121 @@ +import {expect, test, vi} from 'vitest'; +import {AdministrationError, aliasValue, nextRevision, preferenceValues, revisionValue} + from '../src/domain/administration'; +import {administrationJSON, aliasBody, preferencesBody, responseInteger, responseRevision} + from '../src/adapters/administration-json'; + +test.each(['1', '9007199254740991', '9007199254740992', '9007199254740993', + '9223372036854775806', '9223372036854775807'])( + 'administration revisions preserve the exact decimal value %s', value => { + expect(revisionValue(value)).toBe(value); + }); + +test.each([null, undefined, true, false, 1, 9007199254740992, 1n, {}, [], '', '0', '-1', + '+1', '01', '1.0', '1e0', ' 1', '1 ', '1\n', '9', '9223372036854775808', '1'.repeat(100)])( + 'ambiguous or out-of-range revision case %# cannot become an exact request', value => { + expect(() => revisionValue(value)).toThrowError(AdministrationError); + expect(() => revisionValue(value)).toThrow('invalid_revision'); + }); + +test('a successor crosses the JavaScript safe boundary exactly and never wraps int64', () => { + expect(nextRevision(revisionValue('9007199254740992'))).toBe('9007199254740993'); + expect(nextRevision(revisionValue('9223372036854775806'))).toBe('9223372036854775807'); + expect(() => nextRevision(revisionValue('9223372036854775807'))) + .toThrow('revision_exhausted'); +}); + +test.each([null, 'A', 'Literal', 'e\u0301', '😀'.repeat(80), '\uFEFFName\uFEFF'])( + 'a supported alias %j is literal data with no identity or permission', value => { + expect(aliasValue(value)).toBe(value); + }); + +test('alias whitespace uses the backend policy without normalizing Unicode text', () => { + expect(aliasValue(' A\u00a0')).toBe('A'); + expect(aliasValue('\u2003e\u0301\u2003')).toBe('e\u0301'); + expect(aliasValue(' \uFEFFName\uFEFF ')).toBe('\uFEFFName\uFEFF'); +}); + +test.each([undefined, 0, true, {}, [], '', ' ', 'A'.repeat(81), '😀'.repeat(81), + '\tName', '\nName', 'Name\u0000', 'Name\u007f', 'Name\u0085', '\ud800', '\udfff'])( + 'unsupported alias %j fails before trimming or any request', value => { + expect(() => aliasValue(value)).toThrow('invalid_alias'); + }); + +test.each([null, 'en', 'es'] as const)( + 'own preferences accept only the supported locale %s and an exact boolean', locale => { + for (const summaries of [false, true]) { + const result = preferenceValues(locale, summaries); + expect(result).toEqual({locale, routine_participation_summaries: summaries}); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.keys(result)).toHaveLength(2); + } + }); + +test.each([['EN', true], ['future', false], [undefined, true], [1, true], [null, 0], + ['es', 'true'], ['en', null]])('unsupported preference values %j fail closed', (locale, summaries) => { + expect(() => preferenceValues(locale, summaries)).toThrow('invalid_preferences'); +}); + +test('HTTP revision integers are decoded from their original source before Number can round them', () => { + for (const decimal of ['1', '9007199254740993', '9223372036854775807']) { + const decoded = administrationJSON('{"revision":' + decimal + '}') as {revision: unknown}; + expect(responseRevision(decoded.revision)).toBe(decimal); + } +}); + +test('wire strings or lookalike objects cannot forge a numeric revision', () => { + for (const body of ['{"revision":"1"}', '{"revision":{"source":"1"}}', + '{"revision":true}', '{"revision":null}', '{"revision":0}', '{"revision":-1}']) { + const value = administrationJSON(body) as {revision: unknown}; + expect(() => responseRevision(value.revision)).toThrow('invalid_response'); + } +}); + +test('integer conversion requires an exact safe number and never converts a numeric string', () => { + const values = administrationJSON('[0,1,9007199254740991,-1,9007199254740992,"1"]') as unknown[]; + expect(values.slice(0, 3).map(value => responseInteger(value))).toEqual([0, 1, Number.MAX_SAFE_INTEGER]); + for (const value of values.slice(3)) expect(() => responseInteger(value)).toThrow('invalid_response'); +}); + +test('strings stay inert, escaped keys remain unique and real numeric facts retain their type', () => { + const raw = '{"name":"value 9007199254740993 \\\"quoted\\\" \\u003Cscript\\u003E", "list":[false,null,1]}'; + const value = administrationJSON(raw) as {name: string; list: unknown[]}; + expect(value.name).toBe('value 9007199254740993 "quoted" ', revision: 3}})); + timeZone: 'UTC', revision: revisionValue('3')}})); expect(f.root.querySelector('img,script')).toBeNull(); expect(f.text()).toContain(''); expect(f.text()).toContain('household.read'); expect(f.text()).not.toContain(context.installationId); expect(f.text()).not.toContain(context.subject); expect(f.text()).not.toContain('private-id'); @@ -227,7 +228,7 @@ test('native closing and standalone sign-out produce only the semantic logout in }); test('a narrow real layout wraps long inert facts without horizontal main overflow in either locale', async () => { for (const locale of ['en', 'es'] as const) { - const f = await mount(view('active', {household: {householdId: 'home', name: 'X'.repeat(160), timeZone: 'Europe/Madrid', revision: 1}}), locale); + const f = await mount(view('active', {household: {householdId: 'home', name: 'X'.repeat(160), timeZone: 'Europe/Madrid', revision: revisionValue('1')}}), locale); f.element.style.width = '360px'; await f.element.updateComplete; const main = f.root.querySelector('main')!; expect(main.getBoundingClientRect().width).toBe(360); expect(main.scrollWidth).toBeLessThanOrEqual(360); diff --git a/frontend/tests/controller.test.ts b/frontend/tests/controller.test.ts index aacc31b..5e0070b 100644 --- a/frontend/tests/controller.test.ts +++ b/frontend/tests/controller.test.ts @@ -1,3 +1,4 @@ +import {revisionValue} from '../src/domain/administration'; import {expect, test, vi} from 'vitest'; import {EntryController} from '../src/application/entry-controller'; import {EntryReadError, JournalStorageError} from '../src/application/ports'; @@ -19,7 +20,7 @@ const setup: Authorization = {state: 'unconfigured', setupAllowed: true, enrollm const eligible: Authorization = {...setup, state: 'not_enrolled', setupAllowed: false, enrollmentAllowed: true}; const pending: Authorization = {...eligible, state: 'pending', enrollmentAllowed: false}; const active: Authorization = {...pending, state: 'active', permissions: ['household.read', 'household.manage']}; -const household = {householdId: 'home', name: 'Home', timeZone: 'Europe/Madrid', revision: 1}; +const household = {householdId: 'home', name: 'Home', timeZone: 'Europe/Madrid', revision: revisionValue('1')}; const receipt: EntryReceipt = {kind: 'receipt', stage: 'committed', operationId: 'op-original', archivedBy: null, createdAt: 1000, result: {household_id: 'home', revision: 1}}; function fixture(authorization = setup) { diff --git a/frontend/tests/e2e/entry.spec.ts b/frontend/tests/e2e/entry.spec.ts index a768e44..3f7be24 100644 --- a/frontend/tests/e2e/entry.spec.ts +++ b/frontend/tests/e2e/entry.spec.ts @@ -71,6 +71,18 @@ for (const mode of modes) test.describe(mode, () => { expect(bootstrap).not.toMatch(/fixture-access|fixture-supervisor|subject|csrf|installation_id/); }); + test('exact int64 revisions remain readable through the actual Python SQLite and packaged UI path', async ({page, context, request}) => { + await open(page, context, mode); await create(page); + await expect(page.getByRole('heading', {name: 'Your household', level: 1, exact: true})).toBeVisible(); + const before = await stats(request, mode); + // This controls disposable persisted fixtures, not a fabricated sequence of 2^63 mutations. + expect((await request.post(origin(mode) + '/__fixture__/revision', {data: {revision: '9223372036854775807'}})).ok()).toBe(true); + await page.reload(); + await expect(page.getByRole('heading', {name: 'Your household', level: 1, exact: true})).toBeVisible(); + await expect(page.getByText('9223372036854775807', {exact: true})).toBeVisible(); + expect(await stats(request, mode)).toEqual(before); + }); + 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/entry-int64.test.ts b/frontend/tests/entry-int64.test.ts new file mode 100644 index 0000000..82d069f --- /dev/null +++ b/frontend/tests/entry-int64.test.ts @@ -0,0 +1,67 @@ +import {expect, test, vi} from 'vitest'; +import {HttpEntryServer} from '../src/adapters/http'; +import type {JournalContext} from '../src/domain/journal'; +import {wireJSON} from '../src/adapters/http-values'; + +const context: JournalContext = {installationId: '01234567-89ab-cdef-0123-456789abcdef', + channel: 'edge', subject: 'a'.repeat(32)}; +const revisions = ['1', '9007199254740991', '9007199254740992', '9007199254740993', '9223372036854775807']; +function fixture(deployment: 'native' | 'standalone', raw: string) { + const applicationBase = deployment === 'native' ? '/api/hassio_ingress/' + 'b'.repeat(43) + '/' : '/'; + const endpoint = {deployment, applicationBase, origin: 'https://domus.example'}; + const transport = vi.fn(async (path: string, _init: RequestInit) => { + const response = new Response(raw, {headers: {'Content-Type': 'application/json', 'Cache-Control': 'no-store'}}); + Object.defineProperty(response, 'url', {value: endpoint.origin + path}); return response; + }); + return {transport, server: new HttpEntryServer(endpoint, transport)}; +} +function access(revision: string) { + return '{"state":"active","setup_allowed":false,"enrollment_allowed":false,' + + '"permission_scope":"household_administration","permissions":["household.read"],' + + '"member":{"member_id":"member-1","role":"viewer","status":"active","revision":' + revision + + ',"ha_user_id":"' + context.subject + '","exceptions":[]},"policy_revision":' + revision + + ',"effective_role":"administrator","message":"Current access.","action_required":"No action required."}'; +} + +for (const deployment of ['native', 'standalone'] as const) { + test.each(revisions)(`${deployment}: household revision %s remains exact after a real response stream`, async revision => { + const f = fixture(deployment, '{"household_id":"home","name":"Home","time_zone":"UTC","revision":' + revision + '}'); + expect((await f.server.household(context).result).revision).toBe(revision); + expect(f.transport).toHaveBeenCalledTimes(1); + const init = f.transport.mock.calls[0][1]; + expect(init.method).toBe('GET'); expect(init.body).toBeNull(); + expect(new Headers(init.headers).get('X-Domus-Context')).toBe(`v1:${context.installationId}:edge:${context.subject}`); + }); + test.each(revisions)(`${deployment}: high member/policy revision %s cannot make actual authorization unavailable`, async revision => { + const f = fixture(deployment, access(revision)); + expect(await f.server.authorization(context).result).toMatchObject({state: 'active', permissions: ['household.read']}); + expect(f.transport).toHaveBeenCalledTimes(1); + }); + test(`${deployment}: enrollment original receipt preserves a high policy revision without changing delivery or issuing a write`, async () => { + const raw = '{"state":"committed","archived_by":null,"operation_id":"op-1","created_at":1000,' + + '"result":{"member_id":"member-1","status":"pending","role":"viewer","revision":1,' + + '"policy_revision":9223372036854775807},"delivery":"pending","message":"Linked.","action_required":"Review."}'; + const f = fixture(deployment, raw); + expect(await f.server.operation('enroll', 'op-1', context).result).toMatchObject({delivery: 'pending', + result: {revision: '1', policy_revision: '9223372036854775807'}}); + expect(f.transport.mock.calls[0][1].method).toBe('GET'); + expect(new Headers(f.transport.mock.calls[0][1].headers).has('X-CSRF-Token')).toBe(false); + }); +} + +test.each(['"1"', '"9007199254740993"', '{"source":"1"}', 'null', 'true', '0', '-1', + '1.0', '1e0', '9223372036854775808'])( + 'a forged/inexact/out-of-range revision source %s cannot become current facts', async revision => { + const f = fixture('standalone', access(revision)); + await expect(f.server.authorization(context).result).rejects.toThrow('invalid_response'); + }); + +test('the retained safe-number JSON helper keeps its own strict contract independently of exact revision transport', () => { + expect(wireJSON('{"value":12345}')).toEqual({value: 12345}); + expect(() => wireJSON('{"value":9007199254740993}')).toThrow('invalid_response'); +}); + +test('legitimate backend Unicode household text is not rejected by JavaScript trim differences', async () => { + const f = fixture('standalone', '{"household_id":"home","name":"\\ufeff","time_zone":"UTC","revision":1}'); + expect((await f.server.household(context).result).name).toBe('\uFEFF'); +}); diff --git a/frontend/tests/http.test.ts b/frontend/tests/http.test.ts index a95fa25..57c5dc1 100644 --- a/frontend/tests/http.test.ts +++ b/frontend/tests/http.test.ts @@ -225,7 +225,7 @@ test('C21: household read preserves inert historical DEL/C1 text without applyin expect((await f.server.household(context).result).name).toBe('Home\u007f\u0085'); }); test.each([{name:' '},{name:'a'.repeat(161)},{name:'bad\n'},{time_zone:''},{time_zone:'bad\t'}, - {revision:0},{revision:2**53},{household_id:'../home'}])('C21: invalid household %j is unavailable', async patch => { + {revision:0},{revision:2**63},{household_id:'../home'}])('C21: invalid household %j is unavailable', async patch => { const f=fixture(); f.transport.mockResolvedValueOnce(response({household_id:'home',name:'Home',time_zone:'UTC',revision:1,...patch})); await expect(f.server.household(context).result).rejects.toThrow('invalid_response'); }); diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 863971e..25002b0 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) + 2 companion | 168 paths · 2026-10-31 | +| `household-identity` | Proposed | Each participating person uses their own Home Assistant account. Domus associates that verified user with one household member and applies explicit backend permissions independently of an optional person entity. | [Household members, Home Assistant identity and permissions](./household-identity.md) + 3 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 | @@ -38,13 +38,13 @@ debt or convert unknown historic intent into a design decision. ### `household-identity` — Household members, Home Assistant identity and permissions - Owner: Domus maintainers (review assignment pending) -- Last verified: 2026-10-31 -- Specifications: [`specs/household-identity.md`](./household-identity.md) · [`specs/household-administration.md`](./household-administration.md) · [`specs/household-administration-integration.md`](./household-administration-integration.md) +- 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) - 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) -- 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) -- 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) -- 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) +- 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) ### `household-tasks` — Task definitions, occurrences and household execution diff --git a/specs/catalog.json b/specs/catalog.json index 2bcea1e..b22f970 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -225,11 +225,12 @@ "status": "proposed", "scope": "Each participating person uses their own Home Assistant account. Domus associates that verified user with one household member and applies explicit backend permissions independently of an optional person entity.", "owner": "Domus maintainers (review assignment pending)", - "lastVerified": "2026-10-31", + "lastVerified": "2026-11-02", "specs": [ "specs/household-identity.md", "specs/household-administration.md", - "specs/household-administration-integration.md" + "specs/household-administration-integration.md", + "specs/household-administration-workspace.md" ], "workflows": [], "entrypoints": [ @@ -242,7 +243,8 @@ "backend/src/domus/entrypoints/native_http.py", "backend/src/domus/composition/admin.py", "backend/src/domus/entrypoints/console_recovery.py", - "backend/src/domus/composition/ha_app.py" + "backend/src/domus/composition/ha_app.py", + "backend/src/domus/entrypoints/administration_http.py" ], "code": [ "backend/src/domus/domain/identity.py", @@ -299,7 +301,31 @@ "backend/src/domus/infrastructure/offline_storage.py", "backend/src/domus/infrastructure/restore_source.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_commands.py", + "backend/src/domus/application/household_administration_queries.py", + "backend/src/domus/composition/household_administration.py", + "backend/src/domus/entrypoints/administration_dto.py", + "backend/src/domus/entrypoints/administration_http.py", + "backend/src/domus/entrypoints/openapi.py", + "frontend/src/domain/administration.ts", + "frontend/src/adapters/administration-json.ts", + "backend/src/domus/domain/member_sources.py", + "backend/src/domus/application/member_source_ports.py", + "backend/src/domus/application/household_roster.py", + "backend/src/domus/infrastructure/ha_member_profiles.py", + "frontend/src/domain/administration-commands.ts", + "frontend/src/domain/administration-journal.ts", + "frontend/src/application/administration-ports.ts", + "frontend/src/adapters/administration-journal.ts", + "frontend/src/domain/administration-views.ts", + "frontend/src/adapters/json-values.ts", + "frontend/src/adapters/http-transport.ts", + "frontend/src/adapters/administration-values.ts", + "frontend/src/adapters/administration-http.ts", + "frontend/src/application/administration-controller.ts", + "frontend/src/domain/administration-authorization.ts" ], "tests": [ "tests/backend/test_identity_policy.py", @@ -357,7 +383,26 @@ "tests/containers/restore_fixture.py", "tests/backend/test_response_readability.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_administration_http.py", + "tests/containers/test_administration_image.py", + "tests/backend/test_openapi.py", + "frontend/tests/administration.test.ts", + "tests/backend/test_member_sources.py", + "tests/backend/test_household_roster.py", + "tests/backend/test_ha_member_profiles.py", + "tests/backend/test_standalone_member_sources.py", + "frontend/tests/administration-journal.test.ts", + "frontend/tests/administration-session-journal.test.ts", + "frontend/tests/entry-int64.test.ts", + "frontend/tests/administration-http.test.ts", + "frontend/tests/e2e/entry.spec.ts", + "tests/browser/entry_server.py", + "tests/containers/test_app_ui.py", + "tests/packaging/test_ui_wheel.py", + "frontend/tests/administration-controller.test.ts", + "frontend/tests/administration-authorization.test.ts" ], "documentation": [ "docs/product-vision.md", @@ -406,12 +451,13 @@ "docs/evidence/issue6-human-clarifications.json", "docs/evidence/m1-review-fixes.json", "docs/architecture-household-administration.md", - "docs/evidence/household-administration.json" + "docs/evidence/household-administration.json", + "docs/household-administration.md" ], "readiness": { "state": "draft", - "implementationEvidence": "partial: identity/storage, both APIs, snapshots and verified offline restoration; Edge.3 image suites pass on native amd64/aarch64 with actual previous-image upgrade/refusal; owner Edge.3 update/session/reload/restart accepted; complete M1 pending; 79 normative cases traced in acceptance audit; source membership explanations tested in both compositions", - "blockingDecisions": "Complete deployment/live household/recovery/restore, product UI, standalone companion/browser and full channel/release acceptance. No unanswered A30 product choice. Complete current-head Bugbot review remains required. Adult participation approval and narrow task-material defaults are owner-confirmed; their domain implementation remains pending.", + "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.", "copilotChangesAllowed": false } }, diff --git a/specs/household-administration-workspace.md b/specs/household-administration-workspace.md new file mode 100644 index 0000000..4d5a7e5 --- /dev/null +++ b/specs/household-administration-workspace.md @@ -0,0 +1,542 @@ +# Household administration workspace + +- Status: Draft +- Date: 2026-11-02 +- Catalog capability ID: household-identity (companion) +- Last verified: not applicable; this is a prospective specification. +- Owners: Domus maintainers (catalog review assignment pending) +- Scope: Complete the already specified household administration application, protected API, and workspace on the actual merged schema9 baseline. +- Related issues/PRs: [Issue #25](https://github.com/vypdev/domus/issues/25); PR #24 is the merged storage checkpoint described by the supplied evidence. +- Required review gates: product UX, architecture, testing, documentation, security/operations +- Open decisions blocking readiness: None identified. Implementation and acceptance remain outstanding; Draft status and the Action-owned managed-branch readiness gate remain in force. + +This companion adds implementation scope under the existing household-identity capability. It does not replace or reduce `specs/household-identity.md`, `specs/household-administration.md`, or `specs/household-administration-integration.md`. Preserve all HADM-01..HADM-08 requirements and all 72 existing `household-admin-C01` through `household-admin-C72` cases, as well as all eight `household-admin-integration-C01` through `household-admin-integration-C08` refinements and the household-identity and delivery-quality contracts. Those retained cases and refinements are existing owner inventory, not cases in this companion's budget. Owning inventory, workspace refinement rows and parameterized executions must not be added together as independently completed product journeys. A merged checkpoint, this draft, or publication does not establish workspace acceptance. + +## 1. Executive summary + +Complete the household administration workspace in native HA Ingress and standalone deployments. Verified participants can review current identity and access, edit their permitted public alias, and manage their own private preferences. Current administrators can use the existing household, membership, profile, and mapping rules through accessible forms. Canonical Home Assistant identity, current authority, original-operation evidence, and honest provider facts remain authoritative. + +Recommended defaults are no saved alias, browser locale falling back to English, routine summaries enabled as a stored preference, and an empty separate administration journal. Every write and disclosure of an original result requires current authority. Private preference values and inferable private payload/result fingerprints never enter shared audit, events, logs, or roster data. + +```text +Verified entry -> current authorized facts -> explicit revision-bound action -> atomic original operation -> protected receipt and refreshed current state +``` + +## 2. Problem, current behavior, and evidence + +### 2.1 Problem + +Participants need to understand which verified household identity and permissions apply, and to manage public presentation and private preferences safely. Administrators need complete forms for existing household, membership, permission-profile, and mapping operations without bypassing current authorization or last-effective-administrator safeguards. Both deployment modes need equivalent accessible behavior and truthful recovery after races, lost responses, or restoration. + +### 2.2 Current behavior + +1. Supplied immutable evidence identifies actual PR #24 merge baseline `6d84d4815a8bbfe071979df6650d01e433dcc2db`, reviewed head `4e59e04d956668c76f3c8d48e824059fd205d661`, and independently verified exact tree equality. +2. That checkpoint implements schema9 profile storage, separate public/private repositories and revisions, atomic member defaults, strict decoding/CAS/FKs, schema1..8 upgrades, and independent checkpoint profile validation. +3. The checkpoint exposes no new administration HTTP surface and does not complete the workspace or the retained 72-journey acceptance contract. +4. The issue reports backend coverage and both-CPU image/product-suite results for the storage checkpoint. These are baseline evidence only, not execution of this companion's cases or the retained workspace cases. + +### 2.3 Evidence + +- Code/workflow/documentation references: the selected immutable source is `https://github.com/vypdev/domus/tree/6d84d4815a8bbfe071979df6650d01e433dcc2db`; the supplied catalog records existing household identity entrypoints, code, tests, and documentation. Existing owner specifications are `specs/household-identity.md`, `specs/household-administration.md`, and `specs/household-administration-integration.md`. +- Incidents or concrete examples: the issue reports PR #24 as storage-only and explicitly distinguishes checkpoint results from complete administration acceptance. It also records prior publication response-size failures; this companion is the supported new companion route. +- External primary sources: none required to resolve product policy. Provider behavior must be verified against actual supported Home Assistant sources during implementation. +- Unknowns: precise provider availability/denial behavior until verified against the supported source; implementation details and test outcomes for all proposed workspace behavior. + +### 2.4 Retrospective classification (as-built baselines only) + +Not applicable: this is a prospective companion, not a reconstruction of an implemented workspace. Section 2.2 records only the supplied checkpoint facts; proposed behavior is not represented as current. + +## 3. Actors, surfaces, and terminology + +| Actor | Goal | Entry point | Visible surfaces | +|---|---|---|---| +| Verified participant | Review current identity/access and manage permitted own alias and preferences | Existing verified HA session | Ingress or standalone workspace, forms, status and recovery views | +| Current household administrator | Review roster and manage permitted existing household/member/profile/mapping state | Current-authorized administration workspace | Roster, pending review, settings, ordinary profile and mapping forms | +| Operator | Understand original processing, restoration, and compatible rollback | Existing owned-storage and operations tools | Sanitized evidence and operator documentation | + +Canonical HA user ID establishes identity. A display name, Domus alias, or avatar is inert presentation, not identity or permission. Locale and routine-summary choice are owner-private preferences. An original receipt describes original processing; it does not establish current grants or current effects. Technical event/outbox dispatch is not notification delivery. Provider unavailable, denied, and confirmed absence are distinct facts. + +## 4. Goals + +| Requirement | Behavior | +|---|---| +| HADW-01 | Provide currently authorized public-alias commands and queries with independent revision and immutable original-operation key/payload semantics. | +| HADW-02 | Provide owner-only preference commands, queries, and original-result disclosure; prevent private values and inferable private payload/result fingerprints from shared surfaces. | +| HADW-03 | Expose bounded, verified HA name/avatar and directory facts, distinct from identity and permission authority; preserve unavailable, denied, and confirmed-absence states. | +| HADW-04 | Integrate existing household/member/settings/profile/mapping operations and last-effective-administrator safeguards into explicit, current-authorized forms. | +| HADW-05 | Provide protected closed HTTP DTOs, session CSRF/Origin/context validation on every write and original POST replay, protected existing GET result lookup, and a separate bounded journal with exact revisions and context/lifecycle fencing. | +| HADW-06 | Deliver complete accessible EN/ES administration behavior in native Ingress and standalone deployments, including inert rendering and honest save-with-delivery-unavailable status. | +| HADW-07 | Preserve actual schema9, immutable schema1..8 inputs, operation/history and recurrence state, restoration, and compatible runtime rollback without relabeling schema or fabricating migration history. | +| HADW-08 | Preserve all owning contracts and case inventories; deliver tests-first Clean Architecture, full supported global and per-authored-module coverage, actual both-CPU package/runtime acceptance, and user/setup/operator/contributor/migration documentation. | + +### Non-goals + +No gateway or Copilot modification, arbitrary remote images/uploads, person-based authentication, notification-delivery claim, new household policy, new DDL, release/tag/HA rollout, or acceptance of recurrence/calendar issue #23. No retained requirement or case is removed. Owner HA Edge.5/schema7, current rollback, Stable/Edge/all-domain release controls, and gateway integration remain separate unchanged controls. + +### Fixed invariants + +Current authority precedes every effect and original-result disclosure. Expected-revision comparison and immutable original key/payload handling are atomic under concurrent writes. No automatic mutation retry, key replacement, stale-dialog rebase, or private preference disclosure to another administrator is permitted. HTTP and journal formats are closed. Exact revision transport must not round unsafe numeric values. Application work does not change schema9 or frozen schema1..8 inputs. + +## 5. Current versus proposed product journey + +| Stage | Current | Proposed | User/operator effect | +|---|---|---|---| +| Entry | Existing verified setup/join and partial identity APIs | Open the current-authorized workspace | See which current identity and household context is active | +| Review | Checkpoint profile storage exists; complete roster/workspace is absent | Review masked roster, honest source states, permissions, own private preferences, and permitted forms | Understand verified facts, missing facts, and pending review | +| Change | Complete administration commands/forms are absent | Human confirms a closed, revision-bound action under current authority | Stale or denied changes produce no new effect and preserve a safe draft | +| Recover | Existing original-operation owners remain authoritative | Use the separate bounded administration journal and protected original-result lookup | Recover the same original operation without replacing or replaying mutation intent | + +```mermaid +flowchart LR + A[Verified session] --> B[Current authority and permitted facts] + B --> C[Explicit revision-bound form] + C --> D[Authorized atomic operation] + D --> E[Original receipt and refreshed current state] +``` + +Text equivalent: verified session -> current authority and permitted facts -> explicit revision-bound form -> authorized atomic operation -> original receipt and refreshed current state. + +## 6. Functional behavior and state model + +### 6.1 Happy path + +1. Derive principal, installation, household, and current context from the trusted existing session boundary; never accept a client-selected principal. +2. Read permitted local state. Obtain bounded provider facts outside SQLite writer locks, then recheck mutable local grants before an effect. +3. Return public roster/presentation and only the current owner's private preferences. Keep distinct accounts separate even when labels match. +4. On explicit human submission, validate current proof, local grants, CSRF/Origin/context, exact expected revision, and immutable original key/payload. Apply effect, original result, and safe audit/event facts atomically. +5. Before disclosing an original result, reauthorize the original action against current authority. Refresh current state after a saved result. A historical receipt after restore never recreates an absent effect or claims delivery. + +### 6.2 Alternative paths + +- Pending membership requires explicit administrator review. Ordinary activation defaults to restricted and requires explicit confirmation. +- Alias editing changes neither identity nor membership activation. Preferences remain owner-only, including against another household administrator. +- Provider facts can be verified, denied, unavailable, malformed, or confirmed absent. The UI must not collapse these into an empty successful roster or invent a selector. +- Lost responses use protected lookup for the same original request. The client does not automatically resend a mutation, replace a key, or rebase a stale form. +- Locale changes preserve same-context unsaved drafts without saving them. A late response from a retired context cannot replace a newly verified view. + +### 6.3 State machine + +| State | Entered when | User-visible meaning | Allowed next states | Recovery/owner | +|---|---|---|---|---| +| Current | Current context and permitted facts loaded | Current view reflects the verified session | Draft, unavailable, retired | Application/query boundary; refresh or reverify | +| Draft | Human edits an unsaved form | Nothing has been saved yet | Submitted, Current | Presentation; submit explicitly or cancel | +| Submitted | Original request sent | Result is being established | Saved, Uncertain, Blocked | Application; wait for original response | +| Uncertain | Original outcome is unknown | No second mutation has been sent | Saved, Blocked, Current | Protected original-result lookup for same request | +| Saved | Original operation was processed | Receipt is historical processing evidence | Current, Retired | Refresh current state; do not infer current effect or delivery | +| Blocked | Authority, revision, or source is insufficient | No new effect was confirmed | Current, Draft, Unavailable | Explain cause; reverify or refresh after human action | +| Unavailable | Required source cannot provide a fact | Fact is unknown, not absent | Current, Blocked | Bounded read only; no fabricated fact | +| Retired | Logout, context replacement, or lifecycle fence | Old private view is hidden | Current after re-verification | Dispose retired resources; preserve eligible original recovery record | + +Duplicate original requests with identical operation identity and payload resolve through original-operation semantics. A reused key with changed payload is rejected. Stale revisions and concurrent writes cannot produce two successful conflicting effects. Canceled drafts have no effect. Expired or cross-context journal records cannot authorize a read or write. + +## 7. User-facing configuration + +| Input | Type | Recommended default | Allowed values/range | Scope/persistence | +|---|---|---|---|---| +| Public alias | Nullable text | null; use verified HA name when available | null or trimmed 1..80 Unicode codepoints; valid UTF-8; reject Cc before trimming; literal text; only null clears | Public profile, independent revision | +| Locale | Nullable enum | null; browser base locale, then English | null, `en`, `es` | Owner-private preference; explicit choice wins; saved across sessions | +| Routine summary preference | Boolean | true | strict boolean | Owner-private preference; stored choice only, no delivery guarantee | +| Administration journal | Closed record collection | Empty | version 1; at most 8 records, 65,536 total bytes, one-hour lifetime | Separate namespace; immutable original intent/context/lifecycle; no credentials or provider state | +| Revision | Exact integer | Current aggregate revision | 1..2^63-1; reject inexact or unsupported encodings | Independent public/private aggregate revision; operation-time compare-and-set | +| Roster/directory facts | Bounded provider result | Actual permitted facts | At most 256 members; oversized/incomplete source fails honestly | Current query only; no invented fallback facts | + +Validation rejects unknown fields, invalid UTF-8, control characters before trimming, invalid enum values, non-boolean preference values, unsafe numeric coercion, and over-limit records. Alias, preference, and revision values have no cross-field combination that changes authority. Current proof and grants are reread at operation time; submitted intent and original payload are immutable. Null legacy values remain null; unknown future values fail closed rather than being guessed. No credentials, CSRF, cookies, raw provider state, arbitrary settings object, delivery endpoint, executable content, or remote image URL is configurable or persisted in the journal. + +Recommended example: alias null, locale null, summaries true, empty journal. Meaningful alternative: owner selects Spanish, saves a literal alias, and disables summaries; identity and permissions remain unchanged. Correctness, privacy, authority, exact revisions, and immutable original-operation semantics are not configurable. + +Administration capability contract version 2 supplies the server-owned household routing identifier independently of aggregate-read permission. The browser binds household/policy facts and receipts to that verified scope. The identifier is not a household-existence claim, caller selector or authority grant. Legacy unbound capability version 1 and unknown versions fail closed; the journal remains its unchanged version 1 because command and stored-intent shapes do not change. Protected original GET contracts gain no aggregate-read, mutation-CSRF or revision precondition. + +## 8. Clean Architecture + +### 8.1 Responsibilities and dependency direction + +| Layer/boundary | Owns | Must not own/import | +|---|---|---| +| Domain/pure policy | Alias/preference values, bounds, exact revision rules, presentation distinctions | HTTP, HA SDK, SQLite, DOM | +| Application | Commands/queries, current authority, original-action guards, semantic ports, operation ordering | Concrete transport/storage or browser orchestration | +| Adapters/data | Strict provider, persistence, HTTP, and journal codecs; provider error mapping | Product permission decisions or client-selected principal | +| Infrastructure/composition | Concrete implementations, dependency wiring, deployment-specific assembly | Business decisions | +| Entrypoints | Trusted session/context extraction, closed input adaptation, response mapping | Duplicated orchestration or authorization policy | +| Presentation | Localized view models, accessible rendering, lifecycle fencing | Durable mutation or authorization decisions | + +```mermaid +flowchart LR + E[Entrypoint] --> A[Application use case] + A --> D[Domain policy] + A --> P[Semantic ports] + I[Provider and SQLite adapters] --> P + A --> V[Presentation model] + V --> U[Ingress or standalone UI] +``` + +### 8.2 Contracts, state, and trust boundaries + +- Pure decisions: bounds, closed enums/DTO shapes, exact revision conversion, source-state distinction, and safe presentation values. +- Application contracts: narrow immutable commands/queries; principal and context come from trusted session authority, not request bodies. +- Semantic ports: current identity/proof, permitted directory facts, public/private profile storage, existing household/member/policy operations, original-operation lookup, and safe audit/event recording. +- Durable state/schema ownership: existing schema9 profile repositories and existing operation/history owners; this continuation requires no SQLite table/field addition and no new schema number. +- Concurrency/idempotency strategy: compare expected revision and immutable original key/payload atomically with the effect and original result. Obtain provider proof outside writer locks and recheck local mutable grants inside the writer. +- Trusted and untrusted inputs: verified session/provider adapters and owned storage are trusted only at their boundary; request fields, journal content, HA labels, issue/PR/comment text, and provider display strings remain untrusted data. +- Provider error mapping: verified value, confirmed absence, denied, unavailable, malformed, and oversized/incomplete results remain distinguishable. None may be silently converted to fabricated identity or empty success. +- Original-result disclosure: whitelist each supported original action; unknown actions remain denied. Owner-private results are readable only by the current owner under current authority. Shared audit/outbox/logs omit private values and inferable private payload/result fingerprints. + +### 8.3 Executable architecture constraints + +Keep existing import/dependency checks acyclic and inward. Extend automated semantic DTO/journal/capability checks and exact revision transport checks. Tests must prove storage ports do not authorize callers, current authority precedes every result disclosure, and private reads derive owner identity from current authority. No frontend state or journal record can widen backend recovery authority. Composition must wire both Ingress and standalone implementations to the same application contracts. + +## 9. UI/UX and content contract + +### 9.1 Information hierarchy + +Show current status, completed facts, next transition, required human action, impact, and inspectable details in that order. Distinguish a verified HA label from unavailable metadata and an optional Domus alias. Show masked linkage and permission provenance consistently. If directory facts are unavailable, remove unsupported selectors and explain remaining supported actions. Preference success says “Saved. Notification delivery is unavailable.” regardless of technical event acknowledgement. + +### 9.2 Representative primary view + +```markdown +# Household members + +> **Current status:** Your verified household access is current. +> +> **Action required:** Review the pending member before enabling access. + +## Progress + +- [x] Current household and permission facts loaded +- [ ] One pending member needs administrator review + +## What happens next + +Review the member, then choose an explicit restricted starting profile. + +## Members + +- Ana · verified HA account · administrator +- Pending invitation · source details unavailable + +## Your preferences + +Locale: English · Routine summary: on + +
+Technical details + +Current context and revision are available to authorized diagnostics; private values and credentials are excluded. +
+``` + +Text equivalent: current access is loaded, one member is pending review, and an administrator must review that member before a restricted activation. Provider details that are unavailable are labeled as such. + +### 9.3 Concrete state variants + +- Pending/no action: “Your preferences are saved. Notification delivery is unavailable. No action required.” +- Action required: “One member is pending review. Review the verified facts and choose a restricted starting profile.” +- Blocked before effect: “Your changes were not saved because access changed. Refresh to see current access; your draft remains on this verified context.” +- Partially complete: “The original request was processed, but its effect is absent in the restored current state. Review current state before making a new request.” +- Complete: “Alias saved. Current household details have been refreshed.” + +These states must use one primary status and one primary action, descriptive navigation, and progressive disclosure. The workspace does not create or edit issue/PR comments as part of a household action. This is a product-level boundary: no issue/PR/notification side effect is implied by a profile save. + +### 9.4 Accessibility, localization, and responsive behavior + +English and Spanish are supported. Explicit owner locale wins; otherwise use browser base locale and then English. Preserve a same-context unsaved draft on locale change. Provide visible labels and focus, keyboard submission and error focus, status announcements, logical headings, readable 360px reflow, light/dark contrast, and reduced motion. User/provider text is rendered as inert text and never becomes HTML, Markdown, a mention, command, or URL. No arbitrary external image request is permitted. Both Ingress and standalone require real Python/SQLite Playwright journeys and the established Storybook workflow. + +## 10. Failure, recovery, and cleanup + +| Failure/partial state | User impact | Retained facts | Automatic retry | Required action | Cleanup | +|---|---|---|---|---|---| +| Stale revision or lost authority | No new effect; view may be outdated | Safe same-context draft and current server revision | No mutation retry | Refresh and make a new explicit decision | Retain draft only in its valid context | +| Provider denied/unavailable/malformed | Some facts cannot be shown or acted on | Actual identity and precise source status | Bounded read only | Reverify or continue only with supported actions | Do not fabricate an empty success | +| Lost/unreadable response | Original outcome uncertain | Exact original request, key, and context | No mutation resend | Protected lookup for the same original operation | Expire owned journal record after one hour | +| Restore finds original receipt but absent effect | Historical processing differs from current effect | Original receipt and current restored state | No automatic recreation | Inspect current state; require a new human decision for new work | Preserve operation/history fences | +| Logout, context replacement, or late response | Old private view must not reappear | Eligible bounded original record | No | Reverify before rendering or recovery | Dispose retired UI resources; never clear unrelated host storage | + +User-facing errors follow impact -> cause -> action -> retained state and never imply an irreversible success failed. Expire only owned journal records according to its closed limit. Preserve authored inputs, original evidence, pending work, and compatible rollback; clean only terminal owned build/test outputs. + +## 11. Security, permissions, and privacy + +1. Current HA proof and current local grants govern writes and original-result disclosure; current verified context governs reads. Existing bounded read-only outage leases preserve local original-result GET reads until their unchanged expiry; mutations still require full proof. +2. Owner-only preference checks precede administrator/profile grants, including receipts, logs, and projections. Public alias edits require the owner's applicable current permission or current administrator authority. +3. Last-effective-administrator and source checks remain those of existing owners. Session CSRF/Origin/context checks apply to every write and original POST replay. The protected existing GET result lookup gains no mutation-only CSRF or revision requirement. +4. Closed DTOs, exact numeric parsing, operation whitelists, and context-bound journal parsing reject unknown fields/actions, forged or expired records, unsafe numbers, and unsupported encodings before coercion. +5. Operation IDs, journal contents, display strings, forwarded names, and technical credentials never select a principal or widen recovery. Do not persist credentials, CSRF, cookies, raw provider state, private preference values, or inferable private payload/result fingerprints in shared surfaces. + +## 12. Observability and operational UX + +- User-facing state: saved, stale, denied, unavailable, uncertain, and historical-effect-absent are distinct and actionable. +- Job Summary/operator evidence: not applicable to household runtime journeys; operator documentation must instead show sanitized operation/revision/context evidence and actual source/runtime evidence. No workflow result or checkpoint is acceptance of workspace cases. +- Logs, metrics, and correlation: use safe operation/context correlation without private values, private fingerprints, credentials, or raw provider detail. +- Pending external dependency versus workflow failure: provider denial/unavailability is surfaced as a source state; it must not be described as permission success or empty data. +- Rate-limit/retry behavior: bounded provider reads only; no automatic mutation, replacement, rebase, or retry. +- Noise/comment budget: no issue/PR comments or notifications are produced by workspace operations; technical event dispatch is not notification delivery. + +## 13. Compatibility, migration, rollout, and rollback + +Use actual merged schema9. Preserve supported immutable schema1..8 inputs and existing task/recurrence definitions, source/cursors, original operations/history, restoration, and generation fences. No relabeling, new schema number, or fabricated migration chain is allowed. Existing both-CPU upgrade/refusal/current-runtime restore evidence is checkpoint baseline; complete applicable runtime and compatible full application/data/generation rollback acceptance without inferring broader guarantees from narrow tests. Owner HA Edge.5/schema7 and rollback remain unchanged. Release, tags, promotion, and HA adoption are separate work. Unfinished recurrence/calendar work remains issue #23; any later actual DDL uses the next genuinely merged schema after9. Pure application/HTTP/UI work does not alter database history. + +## 14. Testing strategy + +Retain the full original inventory: `household-admin-C01` through `household-admin-C72`, all `household-admin-integration-C01` through `household-admin-integration-C08`, and the household-identity and delivery-quality owner contracts and cases. None is removed or redefined by this companion. + +The former eight compound workspace scenarios understated independently observable risks. Their IDs remain; each now names one concrete behavior, and the remaining assertions are explicitly decomposed below. The minimum is 82 distinct workspace acceptance scenarios across the same eight unchanged requirements. The original 80 rows remain verbatim; two additional cases separately verify receipt-household scope binding and usable recovery without current aggregate-read permission. This is risk decomposition, not 82 completed product journeys or an executed-case claim. Repeated inputs, themes, deployment parameters, storage executions or coverage lines do not increase the inventory count. Each row needs its own evidence; passing a related unit test does not establish a complete browser, image or owner-review case. No full acceptance is claimed by an implementation checkpoint. + +| Area | Minimum distinct cases | Behaviors/risks covered | +|---|---:|---| +| Domain/configuration/pure planning | 9 | Nine independent value/encoding invariants: clear, alias bounds/literal text, exact revision/exhaustion, household text, preferences, unique decisions and coherent mapping. | +| State/application/idempotency/races | 14 | Fourteen independent effect/privacy/recovery invariants: competing writers, original key/payload/operation identity, authority races, private surfaces, independent revisions, expiry, context retention, storage fences, unresolved work and lost responses. | +| Adapters/provider contracts | 11 | Eleven independently observable provider outcomes: verified values, empty/omitted/denied/unavailable/malformed/oversized batches, own OAuth fallback, native subject separation, credential races and trusted avatar availability. | +| Application use cases | 10 | Ten independently authorized transitions: pending activation, settings catalog, target eligibility, explicit mapping, ordinary profile save, last-admin safeguard, public alias scope, read grace, permission provenance and original lookup without aggregate-read permission. | +| Security/abuse | 14 | Fourteen independent trust-boundary risks: GET contract, unknown DTO/action, CSRF, Origin/context, original authority, protected grants, forbidden stored data, record/byte bounds, namespace, numeric evidence, retired responses, session expiry and receipt-household scope binding. | +| Presentation/localization/accessibility/sanitization | 10 | Ten independently inspectable UX outcomes: saved/browser locale, draft retention, inert text, keyboard focus, announced status, narrow layout, themes, reduced motion and honest notification status. | +| Integration/end-to-end/compatibility/migration | 9 | Nine distinct runtime/compatibility paths: historical effect, native/standalone journeys, each CPU image, restore fence, compatible rollback, high-revision entry and incompatible old-image refusal. | +| Workflows/permissions/schemas/setup | 5 | Five independent delivery gates: retained traceability, architecture, source-complete coverage, applicable current-head CI and accurate discoverable guidance. | +| Total | 82 | One independently observable behavior per row; all original owning inventories remain required separately. | + +| Case ID | Area | Requirements | Scenario | +|---|---|---|---| +| household-workspace-C01 | Domain/configuration/pure planning | HADW-01 | Given an existing alias, when the permitted owner explicitly saves null, then only the public alias is cleared. | +| household-workspace-C02 | State/application/idempotency/races | HADW-02 | Given an owner preference receipt, when another household administrator requests it, then no private original result is disclosed. | +| household-workspace-C03 | Adapters/provider contracts | HADW-03 | Given a directory permission denial, when source facts are projected, then the directory is denied rather than a verified empty success. | +| household-workspace-C04 | Application use cases | HADW-04 | Given a pending ordinary membership, when an administrator explicitly activates it with the default form, then its starting profile is restricted. | +| household-workspace-C05 | Security/abuse | HADW-05 | Given current original-action authority, when a protected GET lookup omits mutation CSRF and expected revision, then its existing read contract remains usable. | +| household-workspace-C06 | Presentation/localization/accessibility/sanitization | HADW-06 | Given an explicit saved English or Spanish preference, when the workspace renders, then it overrides the browser language. | +| household-workspace-C07 | Integration/end-to-end/compatibility/migration | HADW-07 | Given a processed operation absent from restored state, when its receipt is recovered, then it is marked historical with absent current effect. | +| household-workspace-C08 | Workflows/permissions/schemas/setup | HADW-08 | Given the original owning contracts, when traceability is audited, then every retained owner case remains required and only inspected evidence supports acceptance. | +| household-workspace-C09 | Domain/configuration/pure planning | HADW-01 | Given boundary or malformed alias text, when pure validation runs, then Unicode codepoint limits and Cc rejection before trimming match the backend. | +| household-workspace-C10 | Domain/configuration/pure planning | HADW-05 | Given revisions around 2^53 and the int64 maximum, when browser values are decoded and encoded, then their exact original integer value is retained. | +| household-workspace-C11 | Domain/configuration/pure planning | HADW-05 | Given an aggregate at the maximum supported revision, when a successor is required, then exhaustion is rejected without wraparound. | +| household-workspace-C12 | Domain/configuration/pure planning | HADW-04 | Given a household name containing Unicode whitespace, when the proposed command is validated, then backend-compatible blank and length rules apply without replacing original text. | +| household-workspace-C13 | Domain/configuration/pure planning | HADW-02 | Given an own preference command, when its values are validated, then only null/en/es and a strict summary boolean are accepted. | +| household-workspace-C14 | Domain/configuration/pure planning | HADW-01 | Given literal alias text with combining characters or markup, when an original intent is captured, then no HTML interpretation or Unicode normalization changes its text. | +| household-workspace-C15 | Domain/configuration/pure planning | HADW-04 | Given repeated ordinary permission decisions, when member or profile input is validated, then duplicate identities are rejected rather than silently consolidated. | +| household-workspace-C16 | Domain/configuration/pure planning | HADW-04 | Given a mapping mode and target, when the closed value is built, then the all-admin mode has no target and targeted modes require a valid exact target. | +| household-workspace-C17 | State/application/idempotency/races | HADW-01 | Given two independent writers using the same alias revision, when they race, then at most one conflicting alias effect commits. | +| household-workspace-C18 | State/application/idempotency/races | HADW-02 | Given two independent writers using the same preference revision, when they race, then at most one conflicting private effect commits. | +| household-workspace-C19 | State/application/idempotency/races | HADW-01, HADW-02 | Given an original key and identical command payload, when the same request is explicitly replayed, then the original operation is returned without a second effect. | +| household-workspace-C20 | State/application/idempotency/races | HADW-01, HADW-02 | Given an existing request key, when its payload changes, then replay is rejected and the original request remains immutable. | +| household-workspace-C21 | State/application/idempotency/races | HADW-03, HADW-05 | Given source I/O outside SQLite, when membership or policy authority changes during the read, then stale source results are not disclosed. | +| household-workspace-C22 | State/application/idempotency/races | HADW-05 | Given a journal record with a known operation ID, when an outcome is updated, then that ID cannot be replaced or reset to unknown. | +| household-workspace-C23 | State/application/idempotency/races | HADW-02 | Given a private preference write or recovery, when shared audit/events/logs are inspected, then private values and inferable private fingerprints are absent. | +| household-workspace-C24 | State/application/idempotency/races | HADW-01, HADW-02 | Given independent public and private profile revisions, when one aggregate changes, then the other aggregate and its revision remain unchanged. | +| household-workspace-C25 | Adapters/provider contracts | HADW-03 | Given a complete bounded HA directory, when names are projected, then only source-supplied literal names and eligible choices are produced. | +| household-workspace-C26 | Adapters/provider contracts | HADW-03 | Given a verified complete directory containing zero accounts, when it is translated, then available-empty remains distinguishable from source failure. | +| household-workspace-C27 | Adapters/provider contracts | HADW-03 | Given a complete directory omitting one known member, when that member is projected, then confirmed absence is distinct from denied or unavailable facts. | +| household-workspace-C28 | Adapters/provider contracts | HADW-03 | Given provider timeout or outage, when the roster loads, then canonical local membership remains and metadata is honestly unavailable. | +| household-workspace-C29 | Adapters/provider contracts | HADW-03 | Given one malformed source record, when the directory batch is decoded, then the incomplete batch cannot be published as verified partial success. | +| household-workspace-C30 | Adapters/provider contracts | HADW-03 | Given more than 256 source records, when the adapter reads them, then overflow is rejected without truncation into success. | +| household-workspace-C31 | Adapters/provider contracts | HADW-03 | Given denied standalone directory access and a valid OAuth grant, when own-name fallback is read, then the supplied subject must match the verified owner exactly. | +| household-workspace-C32 | Adapters/provider contracts | HADW-03 | Given a native technical credential, when member metadata is read, then the credential account never becomes the participating human. | +| household-workspace-C33 | Application use cases | HADW-04 | Given an authorized settings draft and current revision, when it is explicitly saved, then the actual backend timezone catalog governs acceptance. | +| household-workspace-C34 | Application use cases | HADW-04 | Given a selected administrator target, when current source eligibility cannot be established, then the mapping change has no effect. | +| household-workspace-C35 | Application use cases | HADW-04 | Given a mapping draft, when the administrator selects a mode and target, then no mapping transition occurs before explicit submission. | +| household-workspace-C36 | Application use cases | HADW-04 | Given an ordinary profile-adjustment draft, when it is saved, then the selected adjustment changes while the unedited administrator mapping is preserved. | +| household-workspace-C37 | Application use cases | HADW-04 | Given the last effective administrator, when a member or policy transition would remove that administrator, then the existing safeguard rejects it. | +| household-workspace-C38 | Application use cases | HADW-01 | Given current owner or household-administrator alias authority, when a public alias is edited, then it does not change canonical account linkage or membership. | +| household-workspace-C39 | Security/abuse | HADW-05 | Given unknown request fields or an unsupported action, when the HTTP command boundary parses it, then it is rejected before any effect. | +| household-workspace-C40 | Security/abuse | HADW-05 | Given a missing or forged CSRF proof, when an administration mutation or original POST replay is attempted, then it is blocked. | +| household-workspace-C41 | Security/abuse | HADW-05 | Given a foreign Origin or mismatched context assertion, when an administration write is attempted, then the established session boundary rejects it. | +| household-workspace-C42 | Security/abuse | HADW-02, HADW-05 | Given revoked original-action permission but retained ordinary reads, when an original result is requested, then current original-action authority blocks disclosure. | +| household-workspace-C43 | Security/abuse | HADW-04, HADW-05 | Given a protected permission in a member or profile adjustment, when it is submitted, then ordinary configuration cannot grant or revoke it. | +| household-workspace-C44 | Security/abuse | HADW-05 | Given journal fields containing credentials, CSRF, cookies, provider state or response bodies, when storage is decoded, then the closed schema rejects them. | +| household-workspace-C45 | Security/abuse | HADW-05 | Given eight unexpired journal requests across contexts, when another request is prepared, then the global capacity rejects it without evicting original work. | +| household-workspace-C46 | Security/abuse | HADW-05 | Given encoded journal input at or above its byte boundary, when persistence is attempted, then the 65,536-byte UTF-8 bound is enforced before alteration. | +| household-workspace-C47 | Presentation/localization/accessibility/sanitization | HADW-06 | Given a null locale preference and unsupported browser language, when the initial workspace language is chosen, then English is the fallback. | +| household-workspace-C48 | Presentation/localization/accessibility/sanitization | HADW-06 | Given an unsaved draft, when the owner changes language, then the localized view retains the same unsaved values without submitting them. | +| household-workspace-C49 | Presentation/localization/accessibility/sanitization | HADW-03, HADW-06 | Given HA labels and aliases containing markup, when the roster renders, then they remain inert text without active HTML or navigation. | +| household-workspace-C50 | Presentation/localization/accessibility/sanitization | HADW-06 | Given keyboard-only operation, when the user enters, submits, cancels and returns from a form, then focus remains visible and follows the explicit transition. | +| household-workspace-C51 | Presentation/localization/accessibility/sanitization | HADW-06 | Given loading, uncertain, saved or historical status, when the state changes, then assistive status announcements expose the actual outcome and next action. | +| household-workspace-C52 | Presentation/localization/accessibility/sanitization | HADW-06 | Given a 360px viewport, when every administration form and roster state renders, then primary content and controls reflow without inaccessible horizontal overflow. | +| household-workspace-C53 | Presentation/localization/accessibility/sanitization | HADW-06 | Given light and dark themes, when the same workspace state renders, then meaningful labels, controls and focus indicators retain usable contrast. | +| household-workspace-C54 | Presentation/localization/accessibility/sanitization | HADW-06 | Given reduced-motion preference, when status or form transitions occur, then motion is reduced without hiding the transition or required action. | +| household-workspace-C55 | Integration/end-to-end/compatibility/migration | HADW-06, HADW-07 | Given the native composition with real Python/SQLite, when the administration browser journey runs, then it uses the verified existing HA session without a second login. | +| household-workspace-C56 | Integration/end-to-end/compatibility/migration | HADW-06, HADW-07 | Given the standalone composition with real Python/SQLite, when the administration browser journey runs, then current OAuth-bound identity and private original-result scope remain intact. | +| household-workspace-C57 | Integration/end-to-end/compatibility/migration | HADW-07 | Given the exact current amd64 source image, when it starts with supported owned data, then its actual administration API and frontend runtime execute successfully. | +| household-workspace-C58 | Integration/end-to-end/compatibility/migration | HADW-07 | Given the exact current aarch64 source image, when it starts with supported owned data, then its actual administration API and frontend runtime execute successfully. | +| household-workspace-C59 | Integration/end-to-end/compatibility/migration | HADW-07 | Given a supported restore and later processed-operation proofs, when the original request is encountered, then the retained fence prevents duplicate recreation. | +| household-workspace-C60 | Integration/end-to-end/compatibility/migration | HADW-07 | Given a compatible full application/data/generation rollback bundle, when it is restored, then owned data and original operation history reopen consistently. | +| household-workspace-C61 | Workflows/permissions/schemas/setup | HADW-08 | Given the authored application graph, when architecture checks inspect runtime and type-only imports, then inward boundaries and acyclicity are enforced. | +| household-workspace-C62 | Workflows/permissions/schemas/setup | HADW-08 | Given every authored runtime module, when independent coverage inventories are compared with reports, then supported global and per-module coverage is complete without omitted files. | +| household-workspace-C63 | Workflows/permissions/schemas/setup | HADW-08 | Given the current PR head, when CI Check evaluates its applicable jobs, then all required suites and both real CPU images must pass rather than being skipped. | +| household-workspace-C64 | Workflows/permissions/schemas/setup | HADW-08 | Given the implemented workspace, when user/setup/operator/contributor/migration guidance is checked, then links and examples describe actual behavior and evidence scope. | +| household-workspace-C65 | State/application/idempotency/races | HADW-05 | Given an original journal request, when one hour elapses or the clock moves backward, then retention is never extended and only eligible originals remain visible. | +| household-workspace-C66 | State/application/idempotency/races | HADW-05 | Given pending requests in different installation/channel/subject contexts, when views switch, then foreign private payloads stay hidden while eligible originals are retained. | +| household-workspace-C67 | State/application/idempotency/races | HADW-05 | Given a competing or unconfirmed storage write, when the journal persists, then the stale handle is retired and no original request is silently overwritten. | +| household-workspace-C68 | State/application/idempotency/races | HADW-05 | Given a prepared or uncertain original request, when dismissal is attempted, then it cannot be silently treated as resolved or replaced. | +| household-workspace-C69 | State/application/idempotency/races | HADW-05 | Given a lost mutation response followed by reload, when recovery starts, then no automatic resend, key replacement or revision rebase occurs. | +| household-workspace-C70 | Security/abuse | HADW-05 | Given entry, unknown-version or wrong-namespace journal data, when administration recovery opens it, then it cannot widen the unchanged entry recovery schema. | +| household-workspace-C71 | Security/abuse | HADW-05 | Given numeric strings, rounded Numbers or unsupported integer lexemes in wire responses, when revisions are decoded, then only exact supported integer sources are accepted. | +| household-workspace-C72 | Security/abuse | HADW-02, HADW-05 | Given logout or context replacement during an in-flight response, when it arrives late, then it cannot repopulate the retired private view. | +| household-workspace-C73 | Security/abuse | HADW-05 | Given an expired verified session, when a browser action is attempted, then neither the journal nor a timer renews authority or permits a mutation. | +| household-workspace-C74 | Application use cases | HADW-04, HADW-05 | Given temporary HA outage and an unexpired existing session, when the workspace is used, then only the established bounded local-read grace remains and changes are blocked. | +| household-workspace-C75 | Application use cases | HADW-03, HADW-04 | Given stored roles and HA-derived administrator mapping, when permission provenance is shown, then source grants remain distinguishable from configurable ordinary profiles. | +| household-workspace-C76 | Presentation/localization/accessibility/sanitization | HADW-02, HADW-06 | Given a saved routine-summary preference and a technical event acknowledgement, when success is shown, then notification delivery remains explicitly unavailable. | +| household-workspace-C77 | Adapters/provider contracts | HADW-03 | Given credential refresh, logout, expiry or subject replacement during provider I/O, when the result returns, then private lease/subject fences discard an invalidated result. | +| household-workspace-C78 | Integration/end-to-end/compatibility/migration | HADW-05, HADW-07 | Given legitimate high revisions in both deployments, when entry authorization and household reads precede administration, then exact values remain usable without unsafe-number coercion. | +| household-workspace-C79 | Adapters/provider contracts | HADW-03 | Given available verified avatar facts or no supported avatar source, when metadata is presented, then only bounded trusted facts are used and no arbitrary external image is fetched. | +| household-workspace-C80 | Integration/end-to-end/compatibility/migration | HADW-07 | Given migrated schema9 data and an incompatible old image, when that image attempts to open it, then it refuses safely rather than relabeling or corrupting data. | +| household-workspace-C81 | Security/abuse | HADW-05 | Given independently verified household routing scope, when a recovered receipt names a different household and aggregate facts are unavailable, then the receipt is rejected without a mutation. | +| household-workspace-C82 | Application use cases | HADW-05 | Given current original-action and operation-read authority without aggregate-read permission, when an original receipt matches verified household routing scope, then protected GET recovery remains usable after reload. | + +Tests precede behavior. Require 100% supported statement and branch coverage globally and per authored module, including complete new pure-policy branch coverage. Use deterministic clocks/IDs/fakes, actual SQLite, current permission/replay/race/privacy controls, semantic HTTP/journal/capability checks, and browser assertions. Each PR CI Check runs all applicable suites and actual amd64/aarch64 images. Include Storybook and real Python/SQLite Playwright journeys, adversarial inputs, current authority-loss/race controls, and the retained manual UX review contract. Distinguish agent dogfooding, provider fixtures, owner evidence, and actual acceptance. Report genuine Copilot failures upstream without changing Copilot or waiting for its fix. + +## 15. Documentation and discoverability + +| Path | Change | +|---|---| +| `specs/household-administration-workspace.md` | create | +| `specs/household-identity.md` | reference | +| `specs/household-administration.md` | reference | +| `specs/household-administration-integration.md` | reference | +| `specs/delivery-quality.md` | reference | +| `specs/catalog.json` | update | +| `specs/CATALOG.md` | update | +| `docs/user-guide.md` | update | +| `docs/household-setup.md` | update | +| `docs/operations.md` | update | +| `docs/household-api.md` | update | +| `docs/contracts/household-api.openapi.json` | update | +| `docs/contracts/native-household-api.openapi.json` | update | +| `docs/architecture-household-administration.md` | update | +| `docs/household-administration-migration.md` | reference | +| `backend/src/domus/domain/member_profiles.py` | update | +| `backend/src/domus/domain/member_sources.py` | create | +| `backend/src/domus/application/member_source_ports.py` | create | +| `backend/src/domus/application/household_roster.py` | create | +| `backend/src/domus/application/member_profile_ports.py` | update | +| `backend/src/domus/application/member_profile_access.py` | create | +| `backend/src/domus/application/member_profile_commands.py` | create | +| `backend/src/domus/application/household_administration_queries.py` | create | +| `backend/src/domus/application/household_queries.py` | update | +| `backend/src/domus/infrastructure/ha_member_profiles.py` | create | +| `backend/src/domus/infrastructure/standalone_sessions.py` | update | +| `backend/src/domus/infrastructure/session_access.py` | update | +| `backend/src/domus/application/identity_ports.py` | update | +| `backend/src/domus/infrastructure/ha_websocket.py` | update | +| `backend/src/domus/entrypoints/http.py` | update | +| `backend/src/domus/entrypoints/native_http.py` | update | +| `backend/src/domus/entrypoints/administration_http.py` | create | +| `backend/src/domus/entrypoints/administration_dto.py` | create | +| `backend/src/domus/entrypoints/http_errors.py` | update | +| `backend/src/domus/entrypoints/http_contract.py` | update | +| `backend/src/domus/entrypoints/household_http.py` | update | +| `backend/src/domus/entrypoints/task_dto.py` | update | +| `backend/src/domus/entrypoints/openapi.py` | create | +| `backend/src/domus/composition/household_administration.py` | create | +| `backend/src/domus/composition/ingress.py` | update | +| `backend/src/domus/composition/server.py` | update | +| `frontend/src/domain/administration.ts` | create | +| `frontend/src/domain/administration-views.ts` | create | +| `frontend/src/application/ports.ts` | update | +| `frontend/src/adapters/http.ts` | update | +| `frontend/src/adapters/http-values.ts` | update | +| `frontend/src/adapters/json-values.ts` | create | +| `frontend/src/adapters/http-transport.ts` | create | +| `frontend/src/adapters/administration-values.ts` | create | +| `frontend/stories/entry.stories.ts` | update | +| `frontend/tests/http.test.ts` | update | +| `frontend/tests/controller.test.ts` | update | +| `frontend/tests/component.test.ts` | update | +| `frontend/tests/entry-int64.test.ts` | create | +| `frontend/tests/administration-http.test.ts` | create | +| `frontend/tests/e2e/entry.spec.ts` | update | +| `tests/browser/entry_server.py` | update | +| `frontend/src/domain/administration-commands.ts` | create | +| `frontend/src/domain/administration-authorization.ts` | create | +| `frontend/src/domain/administration-journal.ts` | create | +| `frontend/src/application/administration-ports.ts` | create | +| `frontend/src/application/administration-controller.ts` | create | +| `frontend/src/adapters/administration-http.ts` | create | +| `frontend/src/adapters/administration-json.ts` | create | +| `frontend/src/adapters/administration-journal.ts` | create | +| `frontend/src/presentation/domus-administration.ts` | create | +| `frontend/src/presentation/messages.ts` | update | +| `frontend/src/composition/entry-runtime.ts` | update | +| `frontend/stories/administration.stories.ts` | create | +| `frontend/tests/administration.test.ts` | create | +| `frontend/tests/administration-controller.test.ts` | create | +| `frontend/tests/administration-authorization.test.ts` | create | +| `frontend/tests/administration-journal.test.ts` | create | +| `frontend/tests/administration-session-journal.test.ts` | create | +| `frontend/tests/e2e/administration.spec.ts` | create | +| `tests/backend/test_member_profile_commands.py` | create | +| `tests/backend/test_member_sources.py` | create | +| `tests/backend/test_household_roster.py` | create | +| `tests/backend/test_standalone_member_sources.py` | create | +| `tests/backend/test_household_administration_queries.py` | create | +| `tests/backend/test_ha_member_profiles.py` | create | +| `tests/backend/test_administration_http.py` | create | +| `tests/backend/test_openapi.py` | create | +| `tests/backend/test_household_http.py` | update | +| `tests/containers/test_administration_image.py` | update | +| `tests/containers/test_app_ui.py` | update | +| `tests/packaging/test_ui_wheel.py` | update | +| `.github/workflows/ci_check.yml` | update | +| `docs/household-administration.md` | create | +| `docs/household-administration-operations.md` | create | +| `docs/evidence/household-administration.json` | update | + +These code/test paths are proposed implementation targets, not availability or acceptance claims. Declare additional actual paths before implementation. + +User documentation must describe the normal path, defaults, own alias/preferences, locale and delivery-unavailable behavior. Setup documentation covers permissions and deployment prerequisites. Operator documentation covers source states, original recovery, restoration, cleanup, and rollback. Contributor/architecture documentation covers boundaries, contracts, trust, and test evidence. Migration documentation preserves actual schema/history facts. Keep catalog evidence and generated catalog navigation current; validate examples against fixtures/contracts where practical. + +## 16. Acceptance + +1. Given a verified participant with current authority, when they save an alias at the displayed exact revision, then the alias is inert presentation, the original result is bound to its immutable request, and refreshed state shows the current value. +2. Given an owner preference request, when another household administrator attempts to read or recover it, then the request is denied and no private value or inferable fingerprint appears in shared surfaces. +3. Given a directory provider that is denied, unavailable, or confirms absence, when the workspace renders, then it labels the source state accurately and does not fabricate an empty success or unsupported selector. +4. Given an administrator reviewing pending membership, when they explicitly activate it, then current authority and last-effective-administrator safeguards are checked and the starting profile is restricted. +5. Given a lost response, when the user performs protected lookup for the same original POST, then current authority is checked before disclosure and no automatic mutation replay or GET-only CSRF/revision requirement is introduced. +6. Given logout or context replacement while a response is in flight, when that late response arrives, then it cannot restore retired private data; the bounded journal contains no credentials, CSRF, cookies, provider state, or automatic mutation instructions. +7. Given an owner switching English/Spanish with an unsaved draft, when the view re-renders at 360px in light or dark mode, then the draft remains unsaved and keyboard, focus, status announcements, and inert text behavior are usable. +8. Given the actual schema9 baseline and retained owner contracts, when implementation acceptance is performed, then supported restoration/rollback and both CPU runtime paths preserve operation/history fences, all retained cases remain required, and docs/catalog/coverage evidence is actual rather than inferred from the checkpoint. + +Acceptance scenarios are prospective requirements, not executed tests. Readiness remains Draft until implementation evidence and required review gates exist and the Action-owned postcondition is satisfied: the issue is admitted, the exact managed branch is Action-published and independently verified before source work. No contributor branch is created. + +## 17. Requirements traceability + +| Requirement | Cases | +|---|---| +| HADW-01 | household-workspace-C01, household-workspace-C09, household-workspace-C14, household-workspace-C17, household-workspace-C19, household-workspace-C20, household-workspace-C24, household-workspace-C38 | +| HADW-02 | household-workspace-C02, household-workspace-C13, household-workspace-C18, household-workspace-C19, household-workspace-C20, household-workspace-C23, household-workspace-C24, household-workspace-C42, household-workspace-C72, household-workspace-C76 | +| HADW-03 | household-workspace-C03, household-workspace-C21, household-workspace-C25, household-workspace-C26, household-workspace-C27, household-workspace-C28, household-workspace-C29, household-workspace-C30, household-workspace-C31, household-workspace-C32, household-workspace-C49, household-workspace-C75, household-workspace-C77, household-workspace-C79 | +| HADW-04 | household-workspace-C04, household-workspace-C12, household-workspace-C15, household-workspace-C16, household-workspace-C33, household-workspace-C34, household-workspace-C35, household-workspace-C36, household-workspace-C37, household-workspace-C43, household-workspace-C74, household-workspace-C75 | +| HADW-05 | household-workspace-C05, household-workspace-C10, household-workspace-C11, household-workspace-C21, household-workspace-C22, household-workspace-C39, household-workspace-C40, household-workspace-C41, household-workspace-C42, household-workspace-C43, household-workspace-C44, household-workspace-C45, household-workspace-C46, household-workspace-C65, household-workspace-C66, household-workspace-C67, household-workspace-C68, household-workspace-C69, household-workspace-C70, household-workspace-C71, household-workspace-C72, household-workspace-C73, household-workspace-C74, household-workspace-C78, household-workspace-C81, household-workspace-C82 | +| HADW-06 | household-workspace-C06, household-workspace-C47, household-workspace-C48, household-workspace-C49, household-workspace-C50, household-workspace-C51, household-workspace-C52, household-workspace-C53, household-workspace-C54, household-workspace-C55, household-workspace-C56, household-workspace-C76 | +| HADW-07 | household-workspace-C07, household-workspace-C55, household-workspace-C56, household-workspace-C57, household-workspace-C58, household-workspace-C59, household-workspace-C60, household-workspace-C78, household-workspace-C80 | +| HADW-08 | household-workspace-C08, household-workspace-C61, household-workspace-C62, household-workspace-C63, household-workspace-C64 | + +## 18. Implementation sequence + +1. Confirm the actual PR #24 merge/tree baseline and Action-published managed branch independently; perform no source changes before that gate. +2. Freeze closed domain values, HTTP/journal contracts, operation whitelist, exact revision representation, and architecture checks; write pure-policy tests first. +3. Implement application commands/queries and atomic concurrency, current authority, original-result disclosure, and private-data isolation; test races and restore behavior. +4. Implement provider/persistence/HTTP adapters and both deployment compositions with semantic contract checks. +5. Implement accessible view models, journal lifecycle fencing, EN/ES rendering, Storybook fixtures, and real Python/SQLite Playwright journeys. +6. Complete migration/recovery/security and user/setup/operator/contributor documentation alongside behavior; update catalog evidence and generated navigation. +7. Run all applicable suites, full global and per-module coverage, actual both-CPU package/runtime acceptance, manual UX evidence, and final validation. Preserve the distinctions between checkpoint results and workspace acceptance. + +## 19. Definition of Done + +- [ ] Every normative requirement has acceptance and reciprocal traceability. +- [ ] All 82 workspace acceptance scenarios have actual evidence matching their own scope; related unit counts cannot substitute for browser, image or owner-review acceptance. +- [ ] All original HADM-01..HADM-08 requirements, 72 household-admin cases, eight HADI refinements, and household-identity/delivery-quality contracts remain unchanged and required. +- [ ] Architecture boundaries are implemented and automatically enforced; current authority, original-result disclosure, exact revisions, and private-data isolation pass concurrency and security evidence. +- [ ] Numeric test budget, inventory, distribution, global coverage, and per-authored-module coverage pass; no planned case is represented as executed without evidence. +- [ ] Configuration defaults, limits, validation, persistence, and migration agree with this contract. +- [ ] Ingress and standalone primary states and content examples are implemented and reviewed. +- [ ] Accessibility, EN/ES localization, responsive/light/dark behavior, sanitization, and bounded journal behavior pass. +- [ ] User/setup/operator/contributor/migration documentation is complete and discoverable. +- [ ] Failure, partial success, retry, idempotency, restoration, compatible rollback, security, and cleanup pass. +- [ ] Applicable CI runs all suites and actual amd64/aarch64 images; genuine Copilot failures are reported upstream without Copilot changes or waiting for fixes. +- [ ] Required generated artifacts and repository validation commands pass. +- [ ] `specs/catalog.json` evidence and `specs/CATALOG.md` are current and the original pinned SDD format/consistency validation passes. +- [ ] No readiness-blocking product decision remains unresolved; Draft/readiness and Action-published branch gates are satisfied before source work or readiness claims. + +## 20. References and decisions + +- Primary source: `https://github.com/vypdev/domus/tree/6d84d4815a8bbfe071979df6650d01e433dcc2db`. +- Related specs: `specs/household-identity.md`, `specs/household-administration.md`, `specs/household-administration-integration.md`, and `specs/delivery-quality.md`. +- Related issue/PR: [Issue #25](https://github.com/vypdev/domus/issues/25); PR #24 storage checkpoint as cited by the issue evidence. +- Decision: use the supported companion path under existing household-identity ownership; preserve all existing owner requirements and cases verbatim. Do not duplicate the long proposal into the issue. +- Decision: actual schema9 is the baseline; this application/API/UI continuation requests no DDL or schema relabeling. +- Rejected alternatives: claiming the workspace complete from the storage merge, reducing the 72-case inventory, inventing provider facts, weakening privacy/recovery, modifying gateway/Copilot, or treating publication/checkpoint closure as acceptance. +- Follow-up explicitly outside this spec: recurrence/calendar issue #23, release/tag/HA adoption, and existing Edge.5/schema7, rollback, release, and gateway controls. diff --git a/tests/backend/test_administration_http.py b/tests/backend/test_administration_http.py new file mode 100644 index 0000000..f53b444 --- /dev/null +++ b/tests/backend/test_administration_http.py @@ -0,0 +1,684 @@ +"""Both actual ASGI deployments protect profile state and mutation-free original recovery.""" + +from contextlib import contextmanager +from dataclasses import replace + +import pytest +from test_auth_http import login +from test_household_http import fixture as standalone_fixture +from test_household_http import setup as standalone_setup +from test_household_http import write_headers as standalone_headers +from test_native_http import fixture as native_fixture +from test_native_http import headers as native_headers +from test_native_http import setup as native_setup +from test_task_runtime import api as api + +from domus.domain.errors import IdentityUnavailable +from domus.domain.identity import HumanIdentity, MemberStatus, Permission, PermissionRule +from domus.domain.member_profiles import MemberLocale, MemberPreferences, MemberPresentation + +BASE = "/api/v1/administration" + + +@pytest.mark.parametrize("profile", [False, True]) +def test_original_lookup_preserves_the_saved_domain_message(api, profile): + if profile: + member_id = api.client.get(BASE + "/preferences").json()["member_id"] + route = BASE + f"/members/{member_id}/presentation" + body = {"expected_revision": 1, "alias": "Public alias"} + action = "member.presentation.update" + else: + route = "/api/v1/household" + body = {"expected_revision": 1, "name": "Updated home", "time_zone": "UTC"} + action = "household.update" + saved = api.client.put(route, json=body, headers=api.headers("domain-message")) + assert saved.status_code == 200, saved.text + recovered = api.client.get(BASE + f"/requests/{action}/domain-message") + assert recovered.status_code == 200, recovered.text + assert recovered.json()["message"] == saved.json()["message"] + assert recovered.json()["notification_delivery"] == "unavailable" + + +def test_actual_compositions_bind_profile_authority_without_a_second_login(api): + before = api.client.get(BASE + "/preferences") + assert before.status_code == 200, before.text + saved = api.client.put( + BASE + "/preferences", + json={"expected_revision": 1, "locale": "es", "routine_participation_summaries": False}, + headers=api.headers("preferences"), + ) + assert saved.status_code == 200, saved.text + recovered = api.client.get(BASE + "/requests/member.preferences.update/preferences") + assert recovered.json()["operation_id"] == saved.json()["operation_id"] + with api.store.read() as unit: + assert ( + unit.preferences.get("home", before.json()["member_id"]).locale is MemberLocale.SPANISH + ) + + +@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, _, _, _, _): + schemas = client.get("/api/v1/openapi.json").json()["components"]["schemas"] + checked = [] + + def visit(value, location): + if isinstance(value, dict): + if value.get("type") == "integer" and value.get("maximum", 0) > 2**53: + maximum = value["maximum"] + assert type(maximum) is int, location + assert maximum in (2**53 - 1, 2**63 - 1), location + checked.append(location) + for key, nested in value.items(): + visit(nested, location + "." + key) + elif isinstance(value, list): + for index, nested in enumerate(value): + visit(nested, location + f"[{index}]") + + visit(schemas, "schemas") + assert any("PreferencesRequest" in name for name in checked) + assert any("AliasRequest" in name for name in checked) + assert any("HouseholdUpdateRequest" in name for name in checked) + assert any("PolicyBody" in name for name in checked) + + +@contextmanager +def configured(tmp_path, monkeypatch, mode): + if mode == "native": + runtime, client, source, clock = native_fixture(tmp_path, monkeypatch) + try: + native_setup(client) + from test_native_http import INSTANCE + + from domus.domain.storage import Installation + from domus.infrastructure.sqlite import SQLiteDatabase + + store = SQLiteDatabase(tmp_path / "domus.sqlite3", Installation(INSTANCE, "edge")) + yield client, store, source, clock, lambda key: native_headers(client, key) + finally: + client.close() + runtime.close() + else: + client, store, source, _, _, clock = standalone_fixture(tmp_path) + try: + login(client) + standalone_setup(client) + yield client, store, source, clock, lambda key: standalone_headers(client, key) + finally: + client.close() + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +def test_profiles_and_original_key_lookup_are_available_only_in_the_configured_runtime( + tmp_path, monkeypatch, mode +): + with configured(tmp_path, monkeypatch, mode) as (client, store, _, _, headers): + response = client.get(BASE + "/preferences") + assert response.status_code == 200, response.text + owner = response.json()["member_id"] + assert response.json()["locale"] is None + assert response.json()["routine_participation_summaries"] is True + alias = client.put( + BASE + f"/members/{owner}/presentation", + json={"expected_revision": 1, "alias": " Literal "}, + headers=headers("alias"), + ) + assert alias.status_code == 200 and alias.json()["result"]["alias"] == "Literal" + payload = {"expected_revision": 1, "locale": "es", "routine_participation_summaries": False} + saved = client.put(BASE + "/preferences", json=payload, headers=headers("private")) + assert saved.status_code == 200, saved.text + assert saved.json()["result"]["notification_delivery"] == "unavailable" + assert client.get(BASE + "/preferences").json()["locale"] == "es" + before = store.path.read_bytes() + recovered = client.get(BASE + "/requests/member.preferences.update/private") + assert ( + recovered.status_code == 200 + and recovered.json()["operation_id"] == saved.json()["operation_id"] + ) + assert recovered.json()["notification_delivery"] == "unavailable" + assert ( + client.get(BASE + "/requests/member.preferences.update/unreceived").status_code == 404 + ) + assert store.path.read_bytes() == before + public = client.get(BASE + f"/members/{owner}/presentation").json() + assert public["alias"] == "Literal" and "locale" not in public + assert ( + client.put(BASE + "/preferences", json=payload, headers=headers("private")).json() + == saved.json() + ) + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +@pytest.mark.parametrize("change", ["origin", "csrf", "duplicate_key", "query"]) +def test_profile_write_preserves_origin_csrf_unique_key_and_closed_query( + tmp_path, monkeypatch, mode, change +): + with configured(tmp_path, monkeypatch, mode) as (client, store, _, _, headers): + write = headers("private") + if change == "origin": + write["Origin"] = "https://foreign.invalid" + if change == "csrf": + write["X-CSRF-Token"] = "f" * 64 + final = list(write.items()) + if change == "duplicate_key": + final.append(("Idempotency-Key", "replacement")) + path = BASE + "/preferences" + ("?member_id=forged" if change == "query" else "") + before = store.path.read_bytes() + response = client.put( + path, + json={"expected_revision": 1, "locale": "es", "routine_participation_summaries": False}, + headers=final, + ) + assert response.status_code in (400, 403, 422) and store.path.read_bytes() == before + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +@pytest.mark.parametrize( + "field,value", + [ + ("member_id", "forged"), + ("actor_id", "forged"), + ("locale", "future"), + ("routine_participation_summaries", 0), + ("expected_revision", True), + ("expected_revision", 1.0), + ("expected_revision", 0), + ("expected_revision", 2**63), + ], +) +def test_preferences_dto_is_closed_and_never_selects_an_owner( + tmp_path, monkeypatch, mode, field, value +): + with configured(tmp_path, monkeypatch, mode) as (client, store, _, _, headers): + body = { + "expected_revision": 1, + "locale": None, + "routine_participation_summaries": True, + field: value, + } + before = store.path.read_bytes() + response = client.put(BASE + "/preferences", json=body, headers=headers("invalid")) + assert response.status_code == 422 and store.path.read_bytes() == before + assert "forged" not in response.text + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +def test_original_get_needs_neither_mutation_csrf_nor_revision_and_cannot_widen_entry_recovery( + tmp_path, monkeypatch, mode +): + with configured(tmp_path, monkeypatch, mode) as (client, _, _, _, headers): + saved = client.put( + BASE + "/preferences", + json={"expected_revision": 1, "locale": None, "routine_participation_summaries": False}, + headers=headers("saved"), + ) + assert saved.status_code == 200 + assert client.get(BASE + "/requests/member.preferences.update/saved").status_code == 200 + assert client.get(BASE + "/requests/household.setup/setup").status_code == 422 + assert ( + client.get( + BASE + "/requests/member.preferences.update/saved?expected_revision=1" + ).status_code + == 422 + ) + assert client.get(BASE + "/preferences?member_id=other").status_code == 422 + assert client.get(BASE + "/preferences/other").status_code == 404 + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +def test_profile_reads_keep_bounded_outage_grace_and_changes_remain_blocked( + tmp_path, monkeypatch, mode +): + with configured(tmp_path, monkeypatch, mode) as (client, store, source, _, headers): + write = headers("blocked") + source.error = IdentityUnavailable("ha_unavailable") + before = store.path.read_bytes() + assert client.get(BASE + "/preferences").status_code == 200 + response = client.put( + BASE + "/preferences", + json={"expected_revision": 1, "locale": None, "routine_participation_summaries": False}, + headers=write, + ) + assert response.status_code == 503 and store.path.read_bytes() == before + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +def test_suspension_and_pending_membership_block_profile_access_without_client_role_trust( + tmp_path, monkeypatch, mode +): + with configured(tmp_path, monkeypatch, mode) as (client, store, _, _, headers): + write = headers("private") + preferences = client.get(BASE + "/preferences").json() + with store.write() as unit: + member = unit.members.get("home", preferences["member_id"]) + unit.members.save( + replace(member, revision=member.revision + 1, status=MemberStatus.SUSPENDED), + member.revision, + ) + assert client.get(BASE + "/preferences").status_code == 403 + response = client.put( + BASE + "/preferences", + json={"expected_revision": 1, "locale": "es", "routine_participation_summaries": False}, + headers=write, + ) + assert response.status_code == 403 + + +def switch_administrator(client, source, mode, headers): + human = HumanIdentity("b" * 32, True, True, False) + if mode == "native": + source.people[human.ha_user_id] = human + client.headers["X-Remote-User-Id"] = human.ha_user_id + else: + assert ( + client.post("/api/v1/auth/logout", json={}, headers=headers("logout")).status_code + == 200 + ) + source.human = human + login(client) + joined = client.post( + "/api/v1/members/enrollment", json={"expected_revision": 0}, headers=headers("join") + ) + assert joined.status_code == 200 and joined.json()["result"]["status"] == "active" + assert client.get("/api/v1/authorization").json()["effective_role"] == "administrator" + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +def test_even_another_current_administrator_cannot_read_private_original_by_id_or_key( + tmp_path, monkeypatch, mode +): + with configured(tmp_path, monkeypatch, mode) as (client, store, source, _, headers): + owner = client.get(BASE + "/preferences").json()["member_id"] + saved = client.put( + BASE + "/preferences", + json={"expected_revision": 1, "locale": "es", "routine_participation_summaries": False}, + headers=headers("private"), + ).json() + switch_administrator(client, source, mode, headers) + before = store.path.read_bytes() + assert client.get(BASE + "/requests/member.preferences.update/private").status_code == 404 + assert client.get("/api/v1/operations/" + saved["operation_id"]).status_code == 404 + own = client.get(BASE + "/preferences").json() + assert own["member_id"] != owner and own["locale"] is None + assert own["routine_participation_summaries"] is True + roster = client.get("/api/v1/members").json() + assert all( + "locale" not in row and "routine_participation_summaries" not in row + for row in roster["members"] + ) + assert store.path.read_bytes() == before + public = client.put( + BASE + f"/members/{owner}/presentation", + json={"expected_revision": 1, "alias": "Public alias"}, + headers=headers("public"), + ) + assert public.status_code == 200 + with store.read() as unit: + assert unit.preferences.get("home", owner).locale.value == "es" + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +def test_original_recovery_rechecks_permission_and_event_ack_is_not_notification_delivery( + tmp_path, monkeypatch, mode +): + with configured(tmp_path, monkeypatch, mode) as (client, store, _, _, headers): + saved = client.put( + BASE + "/preferences", + json={"expected_revision": 1, "locale": "es", "routine_participation_summaries": False}, + headers=headers("original"), + ).json() + path = BASE + "/requests/member.preferences.update/original" + assert client.get(path).json()["event_delivery"] == "pending" + with store.write() as unit: + assert unit.outbox.acknowledge(saved["operation_id"]) + recovered = client.get(path).json() + assert recovered["event_delivery"] == "delivered" + assert recovered["notification_delivery"] == "unavailable" + with store.write() as unit: + current = unit.members.get("home", saved["result"]["member_id"]) + unit.members.save( + replace( + current, + revision=current.revision + 1, + exceptions=(PermissionRule(Permission.PREFERENCES_WRITE, False),), + ), + current.revision, + ) + before = store.path.read_bytes() + assert client.get(BASE + "/preferences").status_code == 200 + assert client.get(path).status_code == 403 + assert client.get("/api/v1/operations/" + saved["operation_id"]).status_code == 403 + assert store.path.read_bytes() == before + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +def test_capabilities_describe_only_wired_contract_and_closed_journal_without_changing_state( + tmp_path, monkeypatch, mode +): + with configured(tmp_path, monkeypatch, mode) as (client, store, _, _, _): + before = store.path.read_bytes() + result = client.get(BASE + "/capabilities") + assert result.status_code == 200 + assert result.json() == { + "state": "available", + "contract_version": 2, + "household_id": "home", + "exact_revisions": "json-integer-int64", + "actions": [ + "household.update", + "member.preferences.update", + "member.presentation.update", + "member.update", + "policy.update", + ], + "journal": { + "namespace": "administration", + "version": 1, + "maximum_records": 8, + "maximum_bytes": 65536, + "lifetime_ms": 3600000, + }, + } + assert client.get(BASE + "/capabilities?actor_id=forged").status_code == 422 + assert store.path.read_bytes() == before + + +def test_unconfigured_transport_does_not_claim_administration_routes_exist(tmp_path): + client, _, _, _, _, _ = standalone_fixture(tmp_path, administration=False) + try: + login(client) + assert client.get(BASE + "/capabilities").status_code == 404 + assert BASE + "/preferences" not in client.get("/api/v1/openapi.json").json()["paths"] + finally: + client.close() + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +def test_revisions_cross_the_wire_as_exact_int64_values_beyond_javascript_safe_integer( + tmp_path, monkeypatch, mode +): + with configured(tmp_path, monkeypatch, mode) as (client, store, _, _, headers): + owner = client.get(BASE + "/preferences").json()["member_id"] + revision = 2**53 + 1 + # Seed a genuine supported database revision without billions of unrelated writes. + with store._connection() as connection: + for table in ("member_preferences", "member_presentations"): + connection.execute( + f"UPDATE {table} SET revision=? WHERE member_id=?", (revision, owner) + ) + prefs = client.get(BASE + "/preferences") + assert prefs.json()["revision"] == revision and str(revision) in prefs.text + saved = client.put( + BASE + "/preferences", + content=( + '{"expected_revision":' + + str(revision) + + ',"locale":"es","routine_participation_summaries":false}' + ), + headers={**headers("exact"), "Content-Type": "application/json"}, + ) + assert saved.status_code == 200 and saved.json()["result"]["revision"] == revision + 1 + alias = client.put( + BASE + f"/members/{owner}/presentation", + json={ + "expected_revision": revision, + "alias": None, + }, + headers=headers("exact-alias"), + ) + assert alias.status_code == 200 and alias.json()["result"]["revision"] == revision + 1 + with store.read() as unit: + assert unit.preferences.get("home", owner) == MemberPreferences( + owner, "home", MemberLocale.SPANISH, False, revision + 1 + ) + assert unit.presentations.get("home", owner) == MemberPresentation( + owner, "home", None, revision + 1 + ) + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +@pytest.mark.parametrize("alias", ["", " ", "A" * 81, "\tAlias", "Alias\x00", "\ud800"]) +def test_alias_rejects_invalid_values_before_any_effect(tmp_path, monkeypatch, mode, alias): + import json + + with configured(tmp_path, monkeypatch, mode) as (client, store, _, _, headers): + owner = client.get(BASE + "/preferences").json()["member_id"] + before = store.path.read_bytes() + response = client.put( + BASE + f"/members/{owner}/presentation", + content=json.dumps({"expected_revision": 1, "alias": alias}), + headers={**headers("invalid"), "Content-Type": "application/json"}, + ) + assert response.status_code == 422 and store.path.read_bytes() == before + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +@pytest.mark.parametrize( + "body", [b'{"expected_revision":1,"alias":"A","alias":"B"}', b"\xff", b"[]"] +) +def test_alias_json_boundary_rejects_ambiguous_or_unsupported_encoding( + tmp_path, monkeypatch, mode, body +): + with configured(tmp_path, monkeypatch, mode) as (client, store, _, _, headers): + owner = client.get(BASE + "/preferences").json()["member_id"] + before = store.path.read_bytes() + response = client.put( + BASE + f"/members/{owner}/presentation", + content=body, + headers={**headers("invalid"), "Content-Type": "application/json"}, + ) + assert response.status_code == 422 and store.path.read_bytes() == before + + +def profile_provider(api, *, denied=False, malformed=False): + from domus.domain.errors import Forbidden + + calls = [] + + def request(token, operation): + calls.append(operation) + if operation == "config/auth/list": + if denied: + raise Forbidden("ha_directory_forbidden") + if malformed: + return [{"id": "forged"}] + return [ + {"id": user, "name": "Same", "is_active": True, "system_generated": False} + for user in ("a" * 32, "b" * 32) + ] + [ + {"id": "c" * 32, "name": "Inactive", "is_active": False, "system_generated": False} + ] + assert operation == "auth/current_user" + return { + "id": api.source.human.ha_user_id, + "is_admin": api.source.human.is_administrator, + "name": "Own name", + "credentials": [{"auth_provider_type": "homeassistant"}], + } + + api.source._request = request + return calls + + +def test_both_compositions_publish_real_source_states_and_only_permitted_directory_facts(api): + calls = profile_provider(api) + before = api.store.path.read_bytes() + roster = api.client.get(BASE + "/roster") + assert roster.status_code == 200, roster.text + assert roster.json()["directory_state"] == "available" + metadata = roster.json()["members"][0]["metadata"] + assert metadata == {"state": "available", "name": "Same", "avatar_state": "unavailable"} + directory = api.client.get(BASE + "/directory") + assert directory.status_code == 200, directory.text + assert {row["ha_user_id"] for row in directory.json()["profiles"]} == {"a" * 32, "b" * 32} + assert calls == ["config/auth/list", "config/auth/list"] + assert api.store.path.read_bytes() == before + + +def test_ordinary_wire_roster_omits_other_linkage_from_every_nested_metadata_surface(api): + from dataclasses import replace + + from domus.domain.identity import MemberStatus, Role + + profile_provider(api) + api.user("b" * 32) + enrolled = api.client.post( + "/api/v1/members/enrollment", json={"expected_revision": 0}, headers=api.headers("join") + ) + assert enrolled.status_code == 200 + target = enrolled.json()["result"]["member_id"] + # Existing command acceptance owns activation; this establishes the read-permission fixture. + with api.store.write() as unit: + member = unit.members.get("home", target) + unit.members.save( + replace( + member, + role=Role.RESTRICTED, + status=MemberStatus.ACTIVE, + revision=member.revision + 1, + ), + member.revision, + ) + before = api.store.path.read_bytes() + response = api.client.get(BASE + "/roster") + assert response.status_code == 200, response.text + assert len(response.json()["members"]) == 2 + other = next(row for row in response.json()["members"] if row["member"]["member_id"] != target) + assert other["member"]["ha_user_id"] is None and other["member"]["exceptions"] is None + assert set(other["metadata"]) == {"state", "name", "avatar_state"} + assert "a" * 32 not in response.text + assert api.client.get(BASE + "/directory").status_code == 403 + assert api.store.path.read_bytes() == before + + +def test_provider_denial_keeps_roster_and_only_standalone_own_oauth_metadata(api): + profile_provider(api, denied=True) + response = api.client.get(BASE + "/roster") + assert response.status_code == 200, response.text + value = response.json() + assert value["directory_state"] == "denied" and len(value["members"]) == 1 + metadata = value["members"][0]["metadata"] + assert metadata["state"] == ("available" if api.mode == "standalone" else "denied") + assert metadata["name"] == ("Own name" if api.mode == "standalone" else None) + directory = api.client.get(BASE + "/directory").json() + assert directory == {"state": "denied", "profiles": []} + + +def test_read_scope_is_closed_and_invalid_provider_data_cannot_fabricate_an_empty_directory(api): + profile_provider(api, malformed=True) + before = api.store.path.read_bytes() + result = api.client.get(BASE + "/roster") + assert result.status_code == 200 and len(result.json()["members"]) == 1 + assert result.json()["directory_state"] == "unavailable" + assert api.client.get(BASE + "/directory").json() == {"state": "unavailable", "profiles": []} + assert api.client.get(BASE + "/roster?actor_id=forged").status_code == 422 + assert api.client.get(BASE + "/directory?subject=forged").status_code == 422 + assert api.store.path.read_bytes() == before + + +def test_unconfigured_source_has_explicit_unavailable_facts_without_invented_profiles(tmp_path): + client, _, _, _, _, _ = standalone_fixture(tmp_path) + try: + login(client) + standalone_setup(client) + result = client.get(BASE + "/roster") + assert result.status_code == 200, result.text + assert result.json()["directory_state"] == "unavailable" + assert result.json()["members"][0]["metadata"]["state"] == "unavailable" + finally: + client.close() + + +def test_selected_domus_administration_does_not_claim_ha_directory_permission(api): + from domus.domain.identity import AdministratorMapping, MappingMode, MemberStatus, Role + + profile_provider(api, denied=api.mode == "standalone") + api.user("b" * 32) + joined = api.client.post( + "/api/v1/members/enrollment", + json={"expected_revision": 0}, + headers=api.headers("join-selected"), + ) + assert joined.status_code == 200 + with api.store.write() as unit: + member = unit.members.get("home", joined.json()["result"]["member_id"]) + unit.members.save( + replace( + member, role=Role.VIEWER, status=MemberStatus.ACTIVE, revision=member.revision + 1 + ), + member.revision, + ) + policy = unit.policies.get("home") + unit.policies.save( + replace( + policy, + mapping=AdministratorMapping(MappingMode.SELECTED, "b" * 32), + revision=policy.revision + 1, + ), + policy.revision, + ) + assert api.client.get("/api/v1/session").json()["ha_administrator"] is False + assert api.client.get("/api/v1/authorization").json()["effective_role"] == "administrator" + before = api.store.path.read_bytes() + directory = api.client.get(BASE + "/directory") + assert directory.status_code == 200, directory.text + assert directory.json()["state"] == ("denied" if api.mode == "standalone" else "available") + if api.mode == "standalone": + assert directory.json()["profiles"] == [] + assert api.store.path.read_bytes() == before + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +def test_capabilities_bind_server_household_scope_without_household_read_permission( + tmp_path, monkeypatch, mode +): + with configured(tmp_path, monkeypatch, mode) as (client, store, _, _, _): + with store.read() as unit: + member = next( + member + for member in unit.members.list("home") + if member.status is MemberStatus.ACTIVE + ) + with store.write() as unit: + unit.members.save( + replace( + member, + revision=member.revision + 1, + exceptions=(PermissionRule(Permission.HOUSEHOLD_READ, False),), + ), + member.revision, + ) + assert client.get("/api/v1/household").status_code == 403 + before = store.path.read_bytes() + response = client.get(BASE + "/capabilities") + assert response.status_code == 200, response.text + assert response.json()["contract_version"] == 2 + assert response.json()["household_id"] == "home" + assert store.path.read_bytes() == before + assert client.get(BASE + "/capabilities?household_id=foreign").status_code == 422 + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +@pytest.mark.parametrize("lookup", ["key", "id"]) +def test_original_profile_reads_keep_existing_outage_lease_without_replaying_effect( + tmp_path, monkeypatch, mode, lookup +): + with configured(tmp_path, monkeypatch, mode) as (client, store, source, _, headers): + saved = client.put( + BASE + "/preferences", + json={"expected_revision": 1, "locale": "es", "routine_participation_summaries": False}, + headers=headers("outage-original"), + ) + assert saved.status_code == 200, saved.text + source.error = IdentityUnavailable("ha_unavailable") + assert client.get("/api/v1/session").json()["access"] == "read_only" + path = ( + BASE + "/requests/member.preferences.update/outage-original" + if lookup == "key" + else "/api/v1/operations/" + saved.json()["operation_id"] + ) + before = store.path.read_bytes() + recovered = client.get(path) + assert recovered.status_code == 200, recovered.text + assert recovered.json()["operation_id"] == saved.json()["operation_id"] + assert store.path.read_bytes() == before diff --git a/tests/backend/test_ha_member_profiles.py b/tests/backend/test_ha_member_profiles.py new file mode 100644 index 0000000..ab29b8a --- /dev/null +++ b/tests/backend/test_ha_member_profiles.py @@ -0,0 +1,218 @@ +"""Real bounded HA protocol fixtures establish source facts independently of role authority.""" + +import json + +import pytest +from test_ha_websocket import CURRENT, GRANT, USER, provider + +from domus.domain.errors import Forbidden, IdentityRejected, IdentityUnavailable, InvalidInput +from domus.domain.member_sources import SourceState +from domus.infrastructure.ha_member_profiles import ( + HomeAssistantMemberProfiles, + NativeMemberProfiles, +) +from domus.infrastructure.ha_websocket import HomeAssistantWebSocket + +OTHER = "b" * 32 +ROW = {"id": USER, "name": "Literal", "is_active": True, "system_generated": False} + + +def protocol(responses, calls): + def handle(connection): + connection.send(json.dumps({"type": "auth_required", "ha_version": "fixture"})) + assert json.loads(connection.recv()) == {"type": "auth", "access_token": "fixture-access"} + connection.send(json.dumps({"type": "auth_ok", "ha_version": "fixture"})) + command = json.loads(connection.recv()) + assert command["id"] == 1 + calls.append(command["type"]) + success, result = responses[command["type"]] + connection.send( + json.dumps({"id": 1, "type": "result", "success": success, "result": result}) + ) + + return handle + + +@pytest.mark.parametrize("standalone", [False, True]) +def test_complete_directory_uses_one_fixed_read_and_preserves_exact_subject_and_ineligible_accounts( + standalone, +): + calls = [] + rows = [ + ROW, + {**ROW, "id": OTHER, "is_active": False, "picture": "https://external.invalid/image"}, + ] + with provider(protocol({"config/auth/list": (True, rows)}, calls)) as endpoint: + source = HomeAssistantMemberProfiles( + HomeAssistantWebSocket(endpoint), "fixture-access", USER, GRANT if standalone else None + ) + value = source.read() + assert calls == ["config/auth/list"] + assert value.directory.state is SourceState.AVAILABLE + assert value.profile(USER).profile.name == "Literal" + assert value.profile(OTHER).profile.name == "Literal" + assert value.profile(OTHER).profile.eligible is False + assert not hasattr(value.profile(OTHER).profile, "avatar") + assert not hasattr(value.profile(USER).profile, "is_administrator") + assert "fixture-access" not in repr(source) and "fixture-refresh" not in repr(source) + + +def test_native_directory_denial_never_uses_the_technical_tokens_current_user_as_the_actor(): + calls = [] + with provider(protocol({"config/auth/list": (False, None)}, calls)) as endpoint: + value = HomeAssistantMemberProfiles( + HomeAssistantWebSocket(endpoint), "fixture-access", USER + ).read() + assert calls == ["config/auth/list"] + assert value.directory.state is SourceState.DENIED and value.own.state is SourceState.DENIED + + +def test_standalone_directory_denial_keeps_only_exact_own_oauth_verified_metadata(): + calls = [] + with provider( + protocol( + { + "config/auth/list": (False, None), + "auth/current_user": (True, {**CURRENT, "name": "Own name"}), + }, + calls, + ) + ) as endpoint: + value = HomeAssistantMemberProfiles( + HomeAssistantWebSocket(endpoint), "fixture-access", USER, GRANT + ).read() + assert calls == ["config/auth/list", "auth/current_user"] + assert value.directory.state is SourceState.DENIED and value.directory.profiles == () + assert value.own.state is SourceState.AVAILABLE and value.own.profile.name == "Own name" + assert value.profile(OTHER).state is SourceState.DENIED + + +@pytest.mark.parametrize("rows", [[], [{**ROW, "id": OTHER}]]) +def test_verified_omission_is_absence_and_never_an_unavailable_or_fabricated_own_profile(rows): + calls = [] + with provider(protocol({"config/auth/list": (True, rows)}, calls)) as endpoint: + value = HomeAssistantMemberProfiles( + HomeAssistantWebSocket(endpoint), "fixture-access", USER, GRANT + ).read() + assert value.directory.state is SourceState.AVAILABLE and value.own.state is SourceState.ABSENT + assert calls == ["config/auth/list"] + + +@pytest.mark.parametrize( + "rows", + [ + None, + {}, + [None], + [ROW, ROW], + [ROW] * 257, + [{**ROW, "id": "person.alias"}], + [{**ROW, "is_active": 1}], + [{**ROW, "system_generated": "false"}], + [{"id": USER}], + [{**ROW, "name": "\tInvalid"}], + [{**ROW, "name": "\ud800"}], + [{**ROW, "name": "A" * 257}], + [{**ROW, "name": False}], + ], +) +def test_malformed_or_overflow_directory_never_certifies_a_partial_or_empty_result(rows): + calls = [] + with provider(protocol({"config/auth/list": (True, rows)}, calls)) as endpoint: + value = HomeAssistantMemberProfiles( + HomeAssistantWebSocket(endpoint), "fixture-access", USER + ).read() + assert value.directory.state is SourceState.UNAVAILABLE and value.directory.profiles == () + assert value.own.state is SourceState.UNAVAILABLE + assert calls == ["config/auth/list"] + + +@pytest.mark.parametrize( + "patch", [{"id": OTHER}, {"credentials": []}, {"name": "\tInvalid"}, {"is_admin": 1}] +) +def test_invalid_own_metadata_cannot_replace_proof_or_select_another_subject(patch): + calls = [] + with provider( + protocol( + { + "config/auth/list": (False, None), + "auth/current_user": (True, {**CURRENT, "name": "Own", **patch}), + }, + calls, + ) + ) as endpoint: + value = HomeAssistantMemberProfiles( + HomeAssistantWebSocket(endpoint), "fixture-access", USER, GRANT + ).read() + assert value.own.state is SourceState.UNAVAILABLE and value.own.profile is None + assert value.directory.state is SourceState.DENIED + + +@pytest.mark.parametrize( + "error", + [ + IdentityUnavailable("ha_unavailable"), + IdentityRejected("invalid_ha_protocol"), + InvalidInput("invalid_request"), + ], +) +def test_unavailable_directory_can_keep_a_separately_verified_own_profile(error): + class Lookup: + def _request(self, token, operation): + assert token == "fixture-access" + if operation == "config/auth/list": + raise error + return {**CURRENT, "name": None} + + value = HomeAssistantMemberProfiles(Lookup(), "fixture-access", USER, GRANT).read() + assert value.directory.state is SourceState.UNAVAILABLE + assert value.own.state is SourceState.AVAILABLE and value.own.profile.name is None + + +@pytest.mark.parametrize( + "error,state", + [ + (Forbidden("ha_directory_forbidden"), SourceState.DENIED), + (IdentityUnavailable("ha_unavailable"), SourceState.UNAVAILABLE), + ], +) +def test_own_lookup_failure_is_honest_and_contains_no_raw_provider_data(error, state): + class Lookup: + def _request(self, token, operation): + raise error + + value = HomeAssistantMemberProfiles(Lookup(), "fixture-access", USER, GRANT).read() + assert value.own.state is state and value.own.profile is None + + +@pytest.mark.parametrize( + "token,subject,grant", + [ + ("bad token", USER, None), + ("fixture-access", "forged", None), + ("different", USER, GRANT), + ("fixture-access", USER, "forged"), + ], +) +def test_source_binding_rejects_unsupported_material_before_any_provider_call( + token, subject, grant +): + with pytest.raises(InvalidInput): + HomeAssistantMemberProfiles(None, token, subject, grant) + + +def test_native_binding_uses_current_read_proof_without_adopting_transport_identity(): + from types import SimpleNamespace + + from domus.domain.identity import HumanIdentity + + calls = [] + with provider(protocol({"config/auth/list": (True, [ROW])}, calls)) as endpoint: + source = NativeMemberProfiles( + HomeAssistantWebSocket(endpoint), + "fixture-access", + SimpleNamespace(read=lambda: HumanIdentity(USER, False, True, False)), + ) + value = source.read() + assert value.subject == USER and value.own.profile.ha_user_id == USER + assert "fixture-access" not in repr(source) diff --git a/tests/backend/test_household_http.py b/tests/backend/test_household_http.py index 2004368..7203bbe 100644 --- a/tests/backend/test_household_http.py +++ b/tests/backend/test_household_http.py @@ -17,6 +17,7 @@ from domus.application.policy_commands import PolicyCommands from domus.application.recovery_commands import RecoveryCommands, RecoveryQueries from domus.application.sessions import SessionGuard +from domus.composition.household_administration import administration_services from domus.composition.tasks import task_contexts from domus.domain.errors import IdentityUnavailable from domus.domain.identity import HumanIdentity @@ -38,7 +39,9 @@ def new(self) -> str: return "v" * 43 if self.count % 4 == 2 else f"{self.count:043d}" -def fixture(tmp_path: Path, ui_bundle: UIBundle | None = None) -> tuple: +def fixture( + tmp_path: Path, ui_bundle: UIBundle | None = None, *, administration: bool = True +) -> tuple: identity, oauth, socket, clock = identity_fixture() socket.human = replace(socket.human, is_administrator=True) flow = Authentication(MemoryLoginAttempts(), identity, clock, Entropy()) @@ -71,6 +74,9 @@ def context(session_id: str) -> HouseholdHTTPContext: correlation=lambda: "cor_api", entry=EntryHTTPContext(store.installation, "standalone", clock, clock, True), household_context=context, + administration_context=(lambda session_id: administration_services(context(session_id))) + if administration + else None, task_context=lambda session_id: task_factory(SessionActor(guard, session_id)), ui_bundle=ui_bundle, ) diff --git a/tests/backend/test_household_roster.py b/tests/backend/test_household_roster.py new file mode 100644 index 0000000..18383ba --- /dev/null +++ b/tests/backend/test_household_roster.py @@ -0,0 +1,298 @@ +"""Current proof and local grants fence provider I/O before any public roster disclosure.""" + +from contextlib import contextmanager +from dataclasses import replace +from types import SimpleNamespace + +import pytest +from test_member_commands import configured + +from domus.application.household_roster import HouseholdRosterQueries +from domus.application.member_commands import UpdateMember +from domus.domain.errors import Conflict, Forbidden, IdentityRejected, StorageFailure +from domus.domain.identity import ( + HumanIdentity, + Member, + MemberStatus, + Permission, + PermissionRule, + Role, +) +from domus.domain.member_profiles import MemberPresentation +from domus.domain.member_sources import ( + HAProfile, + MemberSourceSnapshot, + ProfileDirectory, + ProfileResult, + SourceState, +) + + +class Source: + def __init__(self, subject): + profiles = ( + HAProfile("ha_admin", "Same", True), + HAProfile("ha_joiner", "Same", True), + HAProfile("unjoined", "Literal", True), + HAProfile("inactive", "Old", False), + ) + directory = ProfileDirectory(SourceState.AVAILABLE, profiles) + self.value = MemberSourceSnapshot(subject, directory, directory.profile(subject)) + self.callback = lambda: None + self.calls = 0 + + def read(self): + self.calls += 1 + self.callback() + return self.value + + +def fixture(tmp_path, *, administrator=True): + commands, store, actor, _, target = configured(tmp_path) + commands.update("activate", UpdateMember(target, 1, Role.RESTRICTED, MemberStatus.ACTIVE)) + if not administrator: + actor.human = HumanIdentity("ha_joiner", False, True, False) + source = Source(actor.human.ha_user_id) + reader = SimpleNamespace(read=lambda: actor.human) + return HouseholdRosterQueries(store, reader, source), store, actor, source, target + + +def test_roster_keeps_distinct_accounts_public_aliases_and_existing_privileged_masking(tmp_path): + query, store, _, source, target = fixture(tmp_path) + with store.write() as unit: + unit.presentations.save(MemberPresentation(target, "home", "Literal alias", 2), 1) + before = store.path.read_bytes() + result = query.roster() + assert len(result.members) == 2 and source.calls == 1 + assert len({item.member.member_id for item in result.members}) == 2 + assert {item.metadata.name for item in result.members} == {"Same"} + row = next(item for item in result.members if item.member.member_id == target) + assert row.presentation.alias == "Literal alias" and row.presentation.revision == 2 + assert row.member.ha_user_id == "ha_joiner" and row.member.exceptions == () + assert not hasattr(row, "preferences") + assert store.path.read_bytes() == before + + +def test_ordinary_roster_never_exposes_other_account_links_or_unjoined_directory(tmp_path): + query, _, _, _, target = fixture(tmp_path, administrator=False) + result = query.roster() + for row in result.members: + assert row.member.ha_user_id == ("ha_joiner" if row.member.member_id == target else None) + assert row.member.exceptions == (() if row.member.member_id == target else None) + with pytest.raises(Forbidden): + query.directory() + + +def test_ordinary_roster_projection_also_masks_linkage_inside_metadata(tmp_path): + from dataclasses import asdict + + query, _, _, _, target = fixture(tmp_path, administrator=False) + row = next(item for item in query.roster().members if item.member.member_id != target) + metadata = asdict(row)["metadata"] + assert set(metadata) == {"state", "name"} + assert metadata["name"] == "Same" + + +def test_known_accounts_without_names_keep_complete_directory_but_unavailable_labels(tmp_path): + query, _, actor, source, _ = fixture(tmp_path) + directory = ProfileDirectory( + SourceState.AVAILABLE, + tuple(replace(profile, name=None) for profile in source.value.directory.profiles), + ) + source.value = MemberSourceSnapshot( + actor.human.ha_user_id, directory, directory.profile(actor.human.ha_user_id) + ) + assert all( + row.metadata.state is SourceState.UNAVAILABLE and row.metadata.name is None + for row in query.roster().members + ) + assert query.directory().state is SourceState.AVAILABLE + + +def test_only_current_policy_authority_reads_complete_eligible_directory_facts(tmp_path): + query, _, _, source, _ = fixture(tmp_path) + result = query.directory() + assert result.state is SourceState.AVAILABLE + assert {item.ha_user_id for item in result.profiles} == {"ha_admin", "ha_joiner", "unjoined"} + assert ( + next(item for item in result.profiles if item.ha_user_id == "unjoined").name + == "Literal" + ) + assert source.calls == 1 + + +@pytest.mark.parametrize("state", [SourceState.DENIED, SourceState.UNAVAILABLE]) +def test_unavailable_source_retains_real_roster_and_explicit_state_without_fake_choices( + tmp_path, state +): + query, store, actor, source, _ = fixture(tmp_path) + source.value = MemberSourceSnapshot( + actor.human.ha_user_id, ProfileDirectory(state), ProfileResult(state) + ) + before = store.path.read_bytes() + roster = query.roster() + assert len(roster.members) == 2 + assert all(item.metadata.state is state for item in roster.members) + directory = query.directory() + assert directory.state is state and directory.profiles == () + assert store.path.read_bytes() == before + + +def test_metadata_callback_runs_outside_sql_and_current_alias_is_read_after_it(tmp_path): + query, store, _, source, target = fixture(tmp_path) + + def update_alias(): + with store.write() as unit: + unit.presentations.save(MemberPresentation(target, "home", "Concurrent alias", 2), 1) + + source.callback = update_alias + row = next(item for item in query.roster().members if item.member.member_id == target) + assert row.presentation.alias == "Concurrent alias" + + +@pytest.mark.parametrize("kind", ["roster", "directory"]) +def test_local_grants_are_rechecked_after_source_io_before_disclosure(tmp_path, kind): + query, store, actor, source, target = fixture(tmp_path) + + def suspend(): + with store.write() as unit: + member = unit.members.by_user("home", actor.human.ha_user_id) + unit.members.save( + replace(member, status=MemberStatus.SUSPENDED, revision=member.revision + 1), + member.revision, + ) + + source.callback = suspend + with pytest.raises(Forbidden): + getattr(query, kind)() + assert source.calls == 1 + + +@pytest.mark.parametrize("kind", ["roster", "directory"]) +def test_provider_cannot_switch_the_verified_actor_during_io(tmp_path, kind): + query, _, actor, source, _ = fixture(tmp_path) + source.callback = lambda: setattr( + actor, "human", HumanIdentity("ha_joiner", False, True, False) + ) + with pytest.raises(IdentityRejected, match="identity_mismatch"): + getattr(query, kind)() + + +def test_lost_inherited_ha_administration_removes_privileged_roster_fields(tmp_path): + query, _, actor, source, target = fixture(tmp_path, administrator=False) + actor.human = replace(actor.human, is_administrator=True) + source.callback = lambda: setattr(actor, "human", replace(actor.human, is_administrator=False)) + result = query.roster() + other = next(item for item in result.members if item.member.member_id != target) + assert other.member.ha_user_id is None and other.member.exceptions is None + + +def test_lost_inherited_ha_administration_blocks_directory_disclosure(tmp_path): + query, _, actor, source, _ = fixture(tmp_path, administrator=False) + actor.human = replace(actor.human, is_administrator=True) + source.callback = lambda: setattr(actor, "human", replace(actor.human, is_administrator=False)) + with pytest.raises(Forbidden): + query.directory() + + +@pytest.mark.parametrize("change", ["member", "policy"]) +def test_changed_identity_snapshot_requires_a_fresh_view_instead_of_rebasing(tmp_path, change): + query, store, _, source, target = fixture(tmp_path) + + def update(): + with store.write() as unit: + if change == "member": + member = unit.members.get("home", target) + unit.members.save(replace(member, revision=member.revision + 1), member.revision) + else: + policy = unit.policies.get("home") + unit.policies.save(replace(policy, revision=policy.revision + 1), policy.revision) + + source.callback = update + with pytest.raises(Conflict, match="identity_state_changed"): + query.roster() + + +@pytest.mark.parametrize("corrupt", ["wrong_subject", "invalid_result"]) +def test_source_contract_mismatch_fails_closed_without_records_or_grants(tmp_path, corrupt): + query, store, _, source, _ = fixture(tmp_path) + source.value = Source("ha_joiner").value if corrupt == "wrong_subject" else None + before = store.path.read_bytes() + with pytest.raises((IdentityRejected, StorageFailure)): + query.roster() + assert store.path.read_bytes() == before + + +@pytest.mark.parametrize( + "status", [MemberStatus.PENDING, MemberStatus.SUSPENDED, MemberStatus.ARCHIVED] +) +def test_inactive_members_cannot_trigger_a_directory_provider_read(tmp_path, status): + query, store, _, source, target = fixture(tmp_path, administrator=False) + with store.write() as unit: + member = unit.members.get("home", target) + unit.members.save( + replace(member, status=status, revision=member.revision + 1), member.revision + ) + with pytest.raises(Forbidden): + query.roster() + assert source.calls == 0 + + +def test_missing_profile_port_record_is_not_an_initializer(tmp_path): + query, store, _, _, _ = fixture(tmp_path) + + class Broken: + @contextmanager + def read(self): + with store.read() as unit: + yield SimpleNamespace( + members=unit.members, + policies=unit.policies, + presentations=SimpleNamespace(get=lambda *_: None), + ) + + query = replace(query, transactions=Broken()) + before = store.path.read_bytes() + with pytest.raises(StorageFailure, match="invalid_member_profile_record"): + query.roster() + assert store.path.read_bytes() == before + + +def test_an_oversized_actual_local_roster_is_not_sent_to_a_provider_or_truncated(tmp_path): + query, store, _, source, _ = fixture(tmp_path) + with store.write() as unit: + for index in range(255): + unit.members.save( + Member( + f"extra-{index}", + "home", + f"source-{index}", + Role.VIEWER, + MemberStatus.ARCHIVED, + 1, + ), + 0, + ) + with pytest.raises(StorageFailure, match="invalid_identity_record"): + query.roster() + assert source.calls == 0 + + +def test_revoked_ordinary_read_permission_precedes_snapshot_conflict_and_disclosure(tmp_path): + query, store, _, source, target = fixture(tmp_path, administrator=False) + + def revoke(): + 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, + ) + + source.callback = revoke + with pytest.raises(Forbidden): + query.roster() diff --git a/tests/backend/test_member_profile_commands.py b/tests/backend/test_member_profile_commands.py new file mode 100644 index 0000000..4631e47 --- /dev/null +++ b/tests/backend/test_member_profile_commands.py @@ -0,0 +1,671 @@ +"""Current authority, private originals and independent revisions at the real SQLite boundary.""" + +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from dataclasses import asdict, replace +from threading import Barrier + +import pytest +from test_member_commands import configured + +from domus.application.household_administration_queries import HouseholdAdministrationQueries +from domus.application.household_queries import HouseholdQueries +from domus.application.member_commands import UpdateMember +from domus.application.member_profile_access import ( + ALIAS_ACTION, + PREFERENCES_ACTION, + ProfileAuthorization, + require_profile_original, +) +from domus.application.member_profile_commands import ( + AliasUpdate, + MemberProfileCommands, + PreferencesUpdate, +) +from domus.domain.errors import ( + Conflict, + Forbidden, + IdentityUnavailable, + InvalidInput, + NotFound, + StorageFailure, +) +from domus.domain.identity import HumanIdentity, MemberStatus, Permission, PermissionRule, Role +from domus.domain.member_profiles import MemberLocale, MemberPreferences, MemberPresentation +from domus.domain.storage import CommandKey, JsonDocument + + +def fixture(tmp_path, *, role=Role.RESTRICTED, active=True): + membership, store, actor, _, target = configured(tmp_path) + if active: + membership.update("activate", UpdateMember(target, 1, role, MemberStatus.ACTIVE)) + service = MemberProfileCommands(store, actor, membership.clock, membership.identifiers) + return service, store, actor, target + + +def owner(actor): + actor.human = HumanIdentity("ha_joiner", False, True, False) + return actor.human + + +def evidence(store): + with store._connection() as connection: + return tuple( + connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0] + for table in ("receipts", "audit", "outbox") + ) + + +@pytest.mark.parametrize("role", list(Role)) +def test_every_active_profile_can_change_its_own_alias_and_preferences_by_default(tmp_path, role): + service, store, actor, target = fixture(tmp_path, role=role) + human = owner(actor) + public = service.update_alias("alias", AliasUpdate(target, 1, " Literal ")) + private = service.update_preferences( + "preferences", PreferencesUpdate(1, MemberLocale.SPANISH, False) + ) + assert public.command.action == ALIAS_ACTION and private.command.action == PREFERENCES_ACTION + assert public.command.actor_id == private.command.actor_id == "ha_joiner" + queries = HouseholdAdministrationQueries(store, human) + assert queries.presentation(target) == MemberPresentation(target, "home", "Literal", 2) + assert queries.preferences() == MemberPreferences( + target, "home", MemberLocale.SPANISH, False, 2 + ) + assert private.result.decode()["notification_delivery"] == "unavailable" + + +def test_administrator_alias_edit_of_pending_member_preserves_membership_and_private_state( + tmp_path, +): + service, store, actor, target = fixture(tmp_path, active=False) + with store.read() as unit: + original = unit.members.get("home", target) + preferences = unit.preferences.get("home", target) + service.update_alias("pending-alias", AliasUpdate(target, 1, "Pending alias")) + with store.read() as unit: + assert unit.members.get("home", target) == original + assert unit.preferences.get("home", target) == preferences + assert original.status is MemberStatus.PENDING + assert HouseholdAdministrationQueries(store, actor.human).preferences().member_id != target + + +@pytest.mark.parametrize("kind", ["alias", "preferences"]) +def test_replay_retains_original_result_after_another_authorized_change(tmp_path, kind): + service, store, actor, target = fixture(tmp_path) + human = owner(actor) + first = ( + AliasUpdate(target, 1, "First") if kind == "alias" else PreferencesUpdate(1, None, False) + ) + second = ( + AliasUpdate(target, 2, "Later") + if kind == "alias" + else PreferencesUpdate(2, MemberLocale.ENGLISH, True) + ) + method = service.update_alias if kind == "alias" else service.update_preferences + saved = method("original", first) + method("later", second) + before = evidence(store) + assert method("original", first) == saved + original = HouseholdQueries(store, human).operation(saved.operation_id).receipt + assert original.result == saved.result and evidence(store) == before + with pytest.raises(Conflict, match="idempotency_conflict"): + method("original", second) + assert evidence(store) == before + + +@pytest.mark.parametrize("locale", [None, MemberLocale.ENGLISH, MemberLocale.SPANISH]) +@pytest.mark.parametrize("summaries", [True, False]) +def test_private_values_and_inferable_fingerprints_never_reach_shared_audit_or_events( + tmp_path, locale, summaries +): + service, store, actor, _ = fixture(tmp_path) + owner(actor) + receipt = service.update_preferences("private", PreferencesUpdate(1, locale, summaries)) + result = receipt.result.decode() + assert result["locale"] == (locale.value if locale is not None else None) + assert result["routine_participation_summaries"] is summaries + forbidden = { + "locale", + "routine_participation_summaries", + "preferences", + "request", + "result", + "payload_fingerprint", + "result_fingerprint", + } + with store.read() as unit: + entries = [ + entry.detail for entry in unit.audit.entries("home") if entry.command == receipt.command + ] + events = [ + event.payload + for event in unit.outbox.pending() + if event.event_id == receipt.operation_id + ] + for document in (*entries, *events): + assert forbidden.isdisjoint(document.decode()) + assert receipt.payload_fingerprint not in document.encoded + assert receipt.result.fingerprint not in document.encoded + assert "member_id" in document.decode() and "revision" in document.decode() + + +def test_another_administrator_cannot_recover_owner_private_original_even_with_operation_id( + tmp_path, +): + service, store, actor, target = fixture(tmp_path) + human = owner(actor) + saved = service.update_preferences("private", PreferencesUpdate(1, MemberLocale.SPANISH, False)) + assert HouseholdQueries(store, human).operation(saved.operation_id).receipt == saved + administrator = HumanIdentity("ha_admin", True, True, False) + with pytest.raises(NotFound, match="operation_missing"): + HouseholdQueries(store, administrator).operation(saved.operation_id) + assert HouseholdAdministrationQueries(store, administrator).preferences().member_id != target + assert "member_id" not in asdict(PreferencesUpdate(1, None, True)) + assert "household_id" not in asdict(PreferencesUpdate(1, None, True)) + + +@pytest.mark.parametrize("kind", ["alias", "preferences"]) +def test_current_owner_permission_precedes_original_replay_and_lookup(tmp_path, kind): + service, store, actor, target = fixture(tmp_path) + human = owner(actor) + request = ( + AliasUpdate(target, 1, "Alias") if kind == "alias" else PreferencesUpdate(1, None, False) + ) + method = service.update_alias if kind == "alias" else service.update_preferences + receipt = method("original", request) + with store.write() as unit: + current = unit.members.get("home", target) + unit.members.save( + replace( + current, + revision=current.revision + 1, + exceptions=(PermissionRule(Permission.PREFERENCES_WRITE, False),), + ), + current.revision, + ) + before = evidence(store) + for action in ( + lambda: method("original", request), + lambda: HouseholdQueries(store, human).operation(receipt.operation_id), + ): + with pytest.raises(Forbidden, match="permission_denied"): + action() + assert evidence(store) == before + + +@pytest.mark.parametrize( + "status", [MemberStatus.PENDING, MemberStatus.SUSPENDED, MemberStatus.ARCHIVED] +) +def test_inactive_owners_cannot_read_or_mutate_profiles(tmp_path, status): + service, store, actor, target = fixture(tmp_path) + human = owner(actor) + with store.write() as unit: + current = unit.members.get("home", target) + unit.members.save( + replace(current, status=status, revision=current.revision + 1), current.revision + ) + queries = HouseholdAdministrationQueries(store, human) + before = evidence(store) + for action in ( + lambda: queries.preferences(), + lambda: queries.presentation(target), + lambda: service.update_alias("alias", AliasUpdate(target, 1, "Alias")), + lambda: service.update_preferences("prefs", PreferencesUpdate(1, None, False)), + ): + with pytest.raises(Forbidden): + action() + assert evidence(store) == before + + +def test_ordinary_owner_cannot_change_another_members_alias_or_cross_household_scope(tmp_path): + service, store, actor, _ = fixture(tmp_path) + human = owner(actor) + with store.read() as unit: + administrator = unit.members.by_user("home", "ha_admin") + for action in ( + lambda: service.update_alias("other", AliasUpdate(administrator.member_id, 1, "Forged")), + lambda: replace(service, household_id="other").update_preferences( + "other", PreferencesUpdate(1, None, False) + ), + lambda: HouseholdAdministrationQueries(store, human, "other").preferences(), + ): + with pytest.raises(Forbidden): + action() + + +def test_source_outage_blocks_changes_while_an_authorized_local_query_is_read_only(tmp_path): + service, store, actor, target = fixture(tmp_path) + human = owner(actor) + actor.error = IdentityUnavailable("ha_unavailable") + before = store.path.read_bytes() + assert HouseholdAdministrationQueries(store, human).preferences() == MemberPreferences( + target, "home" + ) + with pytest.raises(IdentityUnavailable): + service.update_preferences("prefs", PreferencesUpdate(1, None, False)) + assert store.path.read_bytes() == before + + +@pytest.mark.parametrize("kind", ["alias", "preferences"]) +def test_late_actual_outbox_failure_rolls_back_profile_receipt_and_audit_together(tmp_path, kind): + service, store, actor, target = fixture(tmp_path) + human = owner(actor) + + class FailingTransactions: + def read(self): + return store.read() + + @contextmanager + def write(self): + with store.write() as unit: + unit.members.connection.execute( + "CREATE TRIGGER fail_event BEFORE INSERT ON outbox " + "BEGIN SELECT RAISE(ABORT,'sensitive private fixture'); END" + ) + yield unit + + service = replace(service, transactions=FailingTransactions()) + before = evidence(store) + with pytest.raises(StorageFailure, match="^storage_failure$"): + if kind == "alias": + service.update_alias("alias", AliasUpdate(target, 1, "Alias")) + else: + service.update_preferences("private", PreferencesUpdate(1, MemberLocale.SPANISH, False)) + queries = HouseholdAdministrationQueries(store, human) + assert queries.preferences() == MemberPreferences(target, "home") + assert queries.presentation(target) == MemberPresentation(target, "home") + assert evidence(store) == before + + +@pytest.mark.parametrize("kind", ["alias", "preferences"]) +def test_real_independent_writers_have_one_revision_winner_and_one_original_receipt(tmp_path, kind): + service, store, actor, target = fixture(tmp_path) + owner(actor) + barrier = Barrier(2, timeout=5) + before = evidence(store) + + def attempt(number): + request = ( + AliasUpdate(target, 1, str(number)) + if kind == "alias" + else PreferencesUpdate(1, None, bool(number)) + ) + barrier.wait() + try: + method = service.update_alias if kind == "alias" else service.update_preferences + return method(f"race-{number}", request) + except Conflict as error: + assert error.code == "revision_conflict" + return None + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(attempt, (0, 1))) + assert sum(result is not None for result in results) == 1 + assert evidence(store) == tuple(count + 1 for count in before) + + +@pytest.mark.parametrize( + "field,value", + [ + ("expected_revision", True), + ("expected_revision", 0), + ("expected_revision", 1.0), + ("expected_revision", 2**63), + ("alias", ""), + ("alias", "x\n"), + ("alias", "x" * 81), + ("alias", "\ud800"), + ("member_id", "../other"), + ], +) +def test_alias_request_reuses_closed_domain_bounds(field, value): + values = {"member_id": "member", "expected_revision": 1, "alias": "Alias", field: value} + with pytest.raises(InvalidInput): + AliasUpdate(**values) + + +@pytest.mark.parametrize( + "field,value", + [ + ("expected_revision", False), + ("expected_revision", -1), + ("expected_revision", 1.0), + ("expected_revision", 2**63), + ("locale", "es"), + ("locale", "future"), + ("routine_participation_summaries", 0), + ("routine_participation_summaries", "false"), + ], +) +def test_preferences_request_rejects_ambiguous_values(field, value): + values = { + "expected_revision": 1, + "locale": None, + "routine_participation_summaries": True, + field: value, + } + with pytest.raises(InvalidInput): + PreferencesUpdate(**values) + + +@pytest.mark.parametrize("change", ["actor", "action", "unknown", "private_subject"]) +def test_profile_command_authorization_binds_exact_proven_actor_and_registered_action( + tmp_path, change +): + _, store, actor, target = fixture(tmp_path) + human = owner(actor) + action = "unknown" if change == "unknown" else PREFERENCES_ACTION + subject = target if change == "private_subject" else None + authorization = ProfileAuthorization(human, action, subject) + command = CommandKey( + "home", + "forged" if change == "actor" else human.ha_user_id, + ALIAS_ACTION if change == "action" else action, + "key", + ) + with pytest.raises(Forbidden): + with store.read() as unit: + authorization.require(command, unit) + + +def test_missing_public_subject_and_revoked_private_read_fail_without_initializing_records( + tmp_path, +): + _, store, actor, target = fixture(tmp_path) + human = owner(actor) + queries = HouseholdAdministrationQueries(store, human) + before = store.path.read_bytes() + with pytest.raises(NotFound, match="member_missing"): + queries.presentation("missing") + assert store.path.read_bytes() == before + with store.write() as unit: + current = unit.members.get("home", target) + unit.members.save( + replace( + current, + revision=current.revision + 1, + exceptions=(PermissionRule(Permission.PREFERENCES_READ, False),), + ), + current.revision, + ) + with pytest.raises(Forbidden): + queries.preferences() + + +@pytest.mark.parametrize("kind", ["alias", "preferences"]) +def test_wrong_original_subject_is_not_disclosed_by_profile_operation_lookup(tmp_path, kind): + service, store, actor, target = fixture(tmp_path) + human = owner(actor) + saved = ( + service.update_alias("save", AliasUpdate(target, 1, "Alias")) + if kind == "alias" + else service.update_preferences("save", PreferencesUpdate(1, None, False)) + ) + with store._connection() as connection: + result = saved.result.decode() + result["member_id"] = "other-member" + connection.execute( + "UPDATE receipts SET result=? WHERE operation_id=?", + (JsonDocument.from_value(result).encoded, saved.operation_id), + ) + with pytest.raises(Forbidden): + HouseholdQueries(store, human).operation(saved.operation_id) + + +def test_explicit_null_clears_public_alias_without_changing_private_preferences(tmp_path): + service, store, actor, target = fixture(tmp_path) + human = owner(actor) + service.update_alias("set", AliasUpdate(target, 1, "Before")) + service.update_alias("clear", AliasUpdate(target, 2, None)) + queries = HouseholdAdministrationQueries(store, human) + assert queries.presentation(target) == MemberPresentation(target, "home", None, 3) + assert queries.preferences() == MemberPreferences(target, "home") + + +def test_acknowledged_technical_event_is_not_notification_delivery(tmp_path): + service, store, actor, _ = fixture(tmp_path) + human = owner(actor) + saved = service.update_preferences("save", PreferencesUpdate(1, None, True)) + with store.write() as unit: + assert unit.outbox.acknowledge(saved.operation_id) + view = HouseholdQueries(store, human).operation(saved.operation_id) + assert view.delivered and view.receipt.result.decode()["notification_delivery"] == "unavailable" + + +def test_authorized_admin_missing_alias_subject_produces_no_partial_command(tmp_path): + service, store, _, _ = fixture(tmp_path) + before = evidence(store) + with pytest.raises(Conflict, match="member_missing"): + service.update_alias("missing", AliasUpdate("missing", 1, "Alias")) + assert evidence(store) == before + + +@pytest.mark.parametrize("kind", ["alias", "preferences"]) +def test_permission_loss_after_fresh_source_proof_is_rechecked_in_the_writer(tmp_path, kind): + service, store, actor, target = fixture(tmp_path) + owner(actor) + + class RevokingTransactions: + def read(self): + return store.read() + + @contextmanager + def write(self): + # Controlled independent writer: the grant changes after source proof. + with store.write() as unit: + current = unit.members.get("home", target) + unit.members.save( + replace( + current, + revision=current.revision + 1, + exceptions=(PermissionRule(Permission.PREFERENCES_WRITE, False),), + ), + current.revision, + ) + with store.write() as unit: + yield unit + + service = replace(service, transactions=RevokingTransactions()) + before = evidence(store) + with pytest.raises(Forbidden): + if kind == "alias": + service.update_alias("alias", AliasUpdate(target, 1, "Denied")) + else: + service.update_preferences("preferences", PreferencesUpdate(1, None, False)) + assert evidence(store) == before + with store.read() as unit: + assert unit.presentations.get("home", target) == MemberPresentation(target, "home") + assert unit.preferences.get("home", target) == MemberPreferences(target, "home") + + +@pytest.mark.parametrize("has_policy", [True, False]) +def test_unlinked_or_unconfigured_identity_cannot_gain_profile_authority(tmp_path, has_policy): + service, store, actor, target = fixture(tmp_path) + if has_policy: + actor.human = HumanIdentity("unlinked_ha_admin", True, True, False) + else: + with store._connection() as connection: + connection.execute("DELETE FROM household_policy") + before = evidence(store) + for action in ( + lambda: service.update_alias("alias", AliasUpdate(target, 1, "Denied")), + lambda: HouseholdAdministrationQueries(store, actor.human).preferences(), + ): + with pytest.raises(Forbidden): + action() + assert evidence(store) == before + + +@pytest.mark.parametrize("subject", [None, False, "../unsafe", ""]) +def test_malformed_original_profile_subject_fails_without_disclosing_result(tmp_path, subject): + service, store, actor, _ = fixture(tmp_path) + human = owner(actor) + receipt = service.update_preferences("save", PreferencesUpdate(1, MemberLocale.SPANISH, False)) + with store._connection() as connection: + result = receipt.result.decode() + result["member_id"] = subject + connection.execute( + "UPDATE receipts SET result=? WHERE operation_id=?", + (JsonDocument.from_value(result).encoded, receipt.operation_id), + ) + with pytest.raises(StorageFailure, match="^invalid_operation_record$"): + HouseholdQueries(store, human).operation(receipt.operation_id) + + +@pytest.mark.parametrize("field,value", [("household_id", "other"), ("actor_id", "other")]) +def test_original_profile_guard_rejects_another_command_namespace(tmp_path, field, value): + service, store, actor, _ = fixture(tmp_path) + human = owner(actor) + receipt = service.update_preferences("save", PreferencesUpdate(1, None, False)) + wrong = replace(receipt, command=replace(receipt.command, **{field: value})) + with pytest.raises(Forbidden): + with store.read() as unit: + require_profile_original(unit, human, "home", wrong) + + +def test_missing_preference_repository_result_cannot_initialize_or_disclose_defaults( + tmp_path, monkeypatch +): + from domus.infrastructure.sqlite_member_profiles import SQLitePreferences + + service, store, actor, _ = fixture(tmp_path) + human = owner(actor) + monkeypatch.setattr(SQLitePreferences, "get", lambda *_: None) + before = evidence(store) + for action in ( + lambda: service.update_preferences("save", PreferencesUpdate(1, None, False)), + lambda: HouseholdAdministrationQueries(store, human).preferences(), + ): + with pytest.raises(StorageFailure, match="invalid_member_profile_record"): + action() + assert evidence(store) == before + + +def test_admin_alias_authority_never_overrides_revoked_private_preference_authority(tmp_path): + service, store, actor, target = fixture(tmp_path, role=Role.ADMINISTRATOR) + owner(actor) + with store.write() as unit: + current = unit.members.get("home", target) + unit.members.save( + replace( + current, + revision=current.revision + 1, + exceptions=(PermissionRule(Permission.PREFERENCES_WRITE, False),), + ), + current.revision, + ) + service.update_alias("public", AliasUpdate(target, 1, "Administrator alias")) + with pytest.raises(Forbidden): + service.update_preferences("private", PreferencesUpdate(1, None, False)) + + +@pytest.mark.parametrize("kind", ["alias", "preferences"]) +def test_actual_restoration_retains_private_original_without_recreating_absent_profile_effect( + tmp_path, kind +): + from contextlib import closing + + from test_activation_compatibility import identity_at + from test_sqlite_snapshots import Clock + + from domus.infrastructure.owned_storage import open_storage + from domus.infrastructure.sqlite_activation import SQLiteActivation + from domus.infrastructure.sqlite_restoration import SQLiteRestorations + from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + + service, store, actor, target = fixture(tmp_path) + human = owner(actor) + root = tmp_path / "owned" + root.mkdir(mode=0o700) + (root / "domus.sqlite3").write_bytes(store.path.read_bytes()) + (root / "domus.sqlite3").chmod(0o600) + identity_at(root, store.installation) + with closing(open_storage(root, "edge")) as storage: + service = replace(service, transactions=storage.database) + archive = tmp_path / "snapshot" + checkpoint = SQLiteSnapshots( + storage.database, archive, storage.installation, Clock() + ).create("before") + request = ( + AliasUpdate(target, 1, "After") + if kind == "alias" + else PreferencesUpdate(1, MemberLocale.SPANISH, False) + ) + method = service.update_alias if kind == "alias" else service.update_preferences + saved = method("original", request) + prepared = tmp_path / "prepared" + SQLiteRestorations( + storage.database, archive, prepared, storage.installation, Clock(), checkpoint + ).prepare("restore-profile") + result = SQLiteActivation(root, store.installation, Clock(), prepared).activate() + assert result.state == "active" + with closing(open_storage(root, "edge")) as selected: + service = replace(service, transactions=selected.database) + method = service.update_alias if kind == "alias" else service.update_preferences + before = evidence(selected.database) + replay = method("original", request) + assert replay.historical and replay.result == saved.result + lookup = HouseholdQueries(selected.database, human).operation(saved.operation_id) + assert lookup.receipt.historical and lookup.receipt.result == saved.result + # The real restored receipt retains historical/absent wording at the wire boundary. + from domus.entrypoints.administration_http import _profile_receipt + + response = _profile_receipt(lookup.receipt) + assert response.state == "historical" + assert "absent" in response.message and response.notification_delivery == "unavailable" + queries = HouseholdAdministrationQueries(selected.database, human) + assert queries.preferences() == MemberPreferences(target, "home") + assert queries.presentation(target) == MemberPresentation(target, "home") + assert evidence(selected.database) == before + with pytest.raises(NotFound): + HouseholdQueries( + selected.database, HumanIdentity("ha_admin", True, True, False) + ).operation(saved.operation_id) + + +def test_original_key_lookup_recovers_a_lost_operation_id_without_sending_a_mutation(tmp_path): + service, store, actor, _ = fixture(tmp_path) + human = owner(actor) + saved = service.update_preferences("original-key", PreferencesUpdate(1, None, False)) + before = evidence(store) + queries = HouseholdQueries(store, human) + assert queries.administration_request(PREFERENCES_ACTION, "original-key").receipt == saved + with pytest.raises(NotFound, match="operation_missing"): + queries.administration_request(PREFERENCES_ACTION, "unreceived-key") + assert evidence(store) == before + + +@pytest.mark.parametrize( + "action", ["household.setup", "member.enroll", "recovery.request", "unknown"] +) +def test_administration_key_lookup_cannot_widen_the_entry_recovery_contract(tmp_path, action): + _, store, actor, _ = fixture(tmp_path) + before = evidence(store) + with pytest.raises(Forbidden): + HouseholdQueries(store, actor.human).administration_request(action, "key") + assert evidence(store) == before + + +def test_identical_private_keys_remain_scoped_to_the_verified_actor(tmp_path): + service, store, actor, _ = fixture(tmp_path) + human = owner(actor) + first = service.update_preferences( + "same-key", PreferencesUpdate(1, MemberLocale.SPANISH, False) + ) + administrator = HumanIdentity("ha_admin", True, True, False) + actor.human = administrator + second = service.update_preferences( + "same-key", PreferencesUpdate(1, MemberLocale.ENGLISH, True) + ) + assert ( + HouseholdQueries(store, human) + .administration_request(PREFERENCES_ACTION, "same-key") + .receipt + == first + ) + assert ( + HouseholdQueries(store, administrator) + .administration_request(PREFERENCES_ACTION, "same-key") + .receipt + == second + ) diff --git a/tests/backend/test_member_sources.py b/tests/backend/test_member_sources.py new file mode 100644 index 0000000..fc5f30a --- /dev/null +++ b/tests/backend/test_member_sources.py @@ -0,0 +1,120 @@ +"""Presentation source facts cannot supply an identity or household permission grant.""" + +from dataclasses import FrozenInstanceError + +import pytest + +from domus.domain.errors import InvalidInput +from domus.domain.member_sources import ( + HAProfile, + MemberSourceSnapshot, + ProfileDirectory, + ProfileResult, + SourceState, +) + +USER, OTHER = "a" * 32, "b" * 32 + + +@pytest.mark.parametrize("name", [None, "Name", "Literal", "😀" * 256, " Exact "]) +def test_source_names_remain_bounded_literal_facts_without_role_or_avatar(name): + value = HAProfile(USER, name, True) + assert value.name == name and value.eligible is True + assert not hasattr(value, "role") and not hasattr(value, "avatar") + with pytest.raises(FrozenInstanceError): + value.name = "Changed" + + +@pytest.mark.parametrize("name", [1, False, "", " ", "A" * 257, "\tName", "Name\x7f", "\ud800"]) +def test_invalid_source_text_cannot_become_a_verified_label(name): + with pytest.raises(InvalidInput): + HAProfile(USER, name, True) + + +@pytest.mark.parametrize("eligible", [None, 0, "true"]) +def test_source_eligibility_is_an_exact_boolean(eligible): + with pytest.raises(InvalidInput): + HAProfile(USER, "Name", eligible) + + +def test_available_empty_directory_is_distinct_from_unavailable_and_confirmed_absence(): + empty = ProfileDirectory(SourceState.AVAILABLE) + missing = MemberSourceSnapshot(USER, empty, ProfileResult(SourceState.ABSENT)) + assert missing.profile(USER).state is SourceState.ABSENT + assert missing.profile(OTHER).state is SourceState.ABSENT + for state in (SourceState.DENIED, SourceState.UNAVAILABLE): + snapshot = MemberSourceSnapshot(USER, ProfileDirectory(state), ProfileResult(state)) + assert snapshot.profile(OTHER).state is state + + +def test_denied_directory_can_retain_only_the_exact_own_verified_profile(): + own = HAProfile(USER, "Same name", True) + snapshot = MemberSourceSnapshot( + USER, ProfileDirectory(SourceState.DENIED), ProfileResult(SourceState.AVAILABLE, own) + ) + assert snapshot.profile(USER).profile is own + assert snapshot.profile(OTHER).state is SourceState.DENIED + + +def test_available_directory_keeps_distinct_accounts_and_ineligible_source_facts(): + first, second = HAProfile(USER, "Same", True), HAProfile(OTHER, "Same", False) + snapshot = MemberSourceSnapshot( + USER, + ProfileDirectory(SourceState.AVAILABLE, (first, second)), + ProfileResult(SourceState.AVAILABLE, first), + ) + assert snapshot.profile(USER).profile == first + assert snapshot.profile(OTHER).profile == second + assert snapshot.profile("missing").state is SourceState.ABSENT + + +@pytest.mark.parametrize( + "state,profile", + [ + (SourceState.AVAILABLE, None), + (SourceState.AVAILABLE, "forged"), + (SourceState.DENIED, HAProfile(USER, "Name", True)), + ("available", None), + ], +) +def test_profile_result_rejects_unproven_partial_or_unknown_states(state, profile): + with pytest.raises(InvalidInput): + ProfileResult(state, profile) + + +@pytest.mark.parametrize( + "state,profiles", + [ + (SourceState.ABSENT, ()), + ("available", ()), + (SourceState.AVAILABLE, []), + (SourceState.AVAILABLE, (None,)), + (SourceState.AVAILABLE, (HAProfile(USER, None, True),) * 2), + (SourceState.AVAILABLE, tuple(HAProfile(f"user-{n}", None, True) for n in range(257))), + (SourceState.UNAVAILABLE, (HAProfile(USER, None, True),)), + ], +) +def test_directory_facts_are_closed_unique_complete_and_bounded(state, profiles): + with pytest.raises(InvalidInput): + ProfileDirectory(state, profiles) + + +@pytest.mark.parametrize( + "directory,own", + [ + ("forged", ProfileResult(SourceState.ABSENT)), + (ProfileDirectory(SourceState.UNAVAILABLE), None), + ( + ProfileDirectory(SourceState.DENIED), + ProfileResult(SourceState.AVAILABLE, HAProfile(OTHER, None, True)), + ), + (ProfileDirectory(SourceState.AVAILABLE), ProfileResult(SourceState.UNAVAILABLE)), + ( + ProfileDirectory(SourceState.AVAILABLE, (HAProfile(USER, "Name", True),)), + ProfileResult(SourceState.ABSENT), + ), + ], +) +def test_snapshot_cannot_substitute_a_subject_or_contradict_its_complete_directory(directory, own): + with pytest.raises(InvalidInput): + MemberSourceSnapshot(USER, directory, own) diff --git a/tests/backend/test_openapi.py b/tests/backend/test_openapi.py new file mode 100644 index 0000000..ba59f16 --- /dev/null +++ b/tests/backend/test_openapi.py @@ -0,0 +1,43 @@ +"""The actual OpenAPI projection retains exact declared int64 bounds without widening others.""" + +from fastapi import FastAPI +from pydantic import Field + +from domus.entrypoints.http_contract import NonnegativeRevision, PositiveRevision, StrictModel +from domus.entrypoints.openapi import DomusAPI + + +class Values(StrictModel): + revision: PositiveRevision + policy_revision: NonnegativeRevision + optional: PositiveRevision | None + bounded: int = Field(ge=1, le=100, json_schema_extra={"format": "int64"}) + unmarked: int = Field(ge=1, le=2**63 - 1) + approximate: float = Field(le=2**63 - 1, json_schema_extra={"format": "int64"}) + + +def configured(app): + @app.get("/values", response_model=Values) + def values(): + raise AssertionError("Schema inspection does not execute the endpoint") + + return app + + +def test_explicit_int64_bounds_survive_actual_framework_projection_and_cached_reuse(): + app = configured(DomusAPI()) + schema = app.openapi() + values = schema["components"]["schemas"]["Values"]["properties"] + for item in (values["revision"], values["policy_revision"], values["optional"]["anyOf"][0]): + assert type(item["maximum"]) is int and item["maximum"] == 2**63 - 1 + assert values["bounded"]["maximum"] == 100 + assert type(values["unmarked"]["maximum"]) is float + assert type(values["approximate"]["maximum"]) is float + assert app.openapi() is schema + + +def test_original_framework_control_reproduces_numeric_bound_coercion(): + values = configured(FastAPI()).openapi()["components"]["schemas"]["Values"]["properties"] + assert type(values["revision"]["maximum"]) is float + assert values["revision"]["maximum"] > 2**63 - 1 + assert type(Values.model_json_schema()["properties"]["revision"]["maximum"]) is int diff --git a/tests/backend/test_standalone_member_sources.py b/tests/backend/test_standalone_member_sources.py new file mode 100644 index 0000000..4b33760 --- /dev/null +++ b/tests/backend/test_standalone_member_sources.py @@ -0,0 +1,117 @@ +"""Private grants and original leases fence metadata through logout and refresh races.""" + +from dataclasses import replace +from types import SimpleNamespace + +import pytest +from test_standalone_sessions import fixture as identity_fixture +from test_standalone_sessions import sign_in + +from domus.application.sessions import SessionGuard +from domus.domain.errors import IdentityRejected, IdentityUnavailable, Unauthenticated +from domus.domain.member_sources import ( + HAProfile, + MemberSourceSnapshot, + ProfileDirectory, + ProfileResult, + SourceState, +) +from domus.infrastructure import session_access, standalone_sessions + + +def fixture(monkeypatch): + identity, oauth, socket, clock = identity_fixture() + lease = sign_in(identity) + calls = [] + event = SimpleNamespace(callback=lambda: None) + + def source(lookup, token, subject, grant): + assert lookup is socket and token == grant.access_token + calls.append((token, subject)) + + def read(): + event.callback() + return MemberSourceSnapshot( + subject, + ProfileDirectory(SourceState.DENIED), + ProfileResult(SourceState.AVAILABLE, HAProfile(subject, "Own", True)), + ) + + return SimpleNamespace(read=read) + + monkeypatch.setattr(standalone_sessions, "HomeAssistantMemberProfiles", source, raising=False) + return identity, oauth, socket, clock, lease, calls, event + + +def test_source_uses_exact_original_private_session_and_refreshed_grant(monkeypatch): + identity, _, _, clock, lease, calls, _ = fixture(monkeypatch) + source = session_access.SessionMemberProfiles(identity, lease.session_id) + assert source.read().own.profile.name == "Own" + clock.value += 1_770_000 + assert source.read().subject == lease.human.ha_user_id + assert calls == [ + ("private-access", lease.human.ha_user_id), + ("renewed-access", lease.human.ha_user_id), + ] + assert identity.get(lease.session_id).expires_at == lease.expires_at + assert lease.session_id not in repr(source) and "private-access" not in repr(source) + + +@pytest.mark.parametrize("change", ["expired", "logout", "unknown"]) +def test_missing_or_expired_session_cannot_trigger_metadata_lookup(monkeypatch, change): + identity, _, _, clock, lease, calls, _ = fixture(monkeypatch) + session = lease.session_id + if change == "expired": + clock.value = lease.expires_at + elif change == "logout": + identity.remove(session) + else: + session = "unknown" + with pytest.raises(Unauthenticated): + identity.member_sources(session) + assert calls == [] + + +@pytest.mark.parametrize("change", ["logout", "expiry"]) +def test_metadata_returning_after_retirement_cannot_restore_private_facts(monkeypatch, change): + identity, _, _, clock, lease, _, event = fixture(monkeypatch) + event.callback = ( + (lambda: identity.remove(lease.session_id)) + if change == "logout" + else (lambda: setattr(clock, "value", lease.expires_at)) + ) + with pytest.raises(IdentityRejected, match="session_changed|session_expired"): + identity.member_sources(lease.session_id) + assert identity.get(lease.session_id) is None and lease.session_id not in identity._credentials + + +def test_read_grace_is_retained_without_becoming_fresh_mutation_proof(monkeypatch): + identity, _, socket, clock, lease, calls, _ = fixture(monkeypatch) + socket.error = IdentityUnavailable("ha_unavailable") + assert identity.member_sources(lease.session_id).subject == lease.human.ha_user_id + assert len(calls) == 1 and identity.get(lease.session_id).expires_at == lease.expires_at + with pytest.raises(IdentityUnavailable): + SessionGuard(identity, identity, clock).write(lease.session_id) + + +def test_subject_change_fails_before_accessing_the_old_private_grant(monkeypatch): + identity, _, socket, _, lease, calls, _ = fixture(monkeypatch) + socket.human = replace(socket.human, ha_user_id="b" * 32) + with pytest.raises(IdentityRejected, match="identity_mismatch"): + identity.member_sources(lease.session_id) + assert calls == [] + + +def test_logout_after_read_proof_blocks_credential_preparation(monkeypatch): + identity, _, _, _, lease, calls, _ = fixture(monkeypatch) + original = SessionGuard.read + + def then_logout(guard, session): + human = original(guard, session) + identity.remove(session) + return human + + monkeypatch.setattr(SessionGuard, "read", then_logout) + with pytest.raises(IdentityRejected, match="session_changed"): + identity.member_sources(lease.session_id) + assert calls == [] diff --git a/tests/browser/entry_server.py b/tests/browser/entry_server.py index 062ed89..963159c 100644 --- a/tests/browser/entry_server.py +++ b/tests/browser/entry_server.py @@ -224,6 +224,17 @@ async def __call__(self, scope, receive, send) -> None: response = JSONResponse({"state": "completed", "fixture": True}) elif path == "/__fixture__/stats": response = JSONResponse(self.stats()) + elif path == "/__fixture__/revision" and request.method == "POST": + # Direct disposable fixture setup, not a product command or migration/history claim. + values = await request.json() + assert set(values) == {"revision"} and type(values["revision"]) is str + revision = int(values["revision"]) + assert str(revision) == values["revision"] and 1 <= revision <= 2**63 - 1 + with sqlite3.connect(self.database) as connection: + for table in ("households", "members", "household_policy"): + cursor = connection.execute(f'UPDATE "{table}" SET revision=?', (revision,)) + assert cursor.rowcount > 0 + response = JSONResponse({"state": "changed", "fixture": True}) elif path == "/__fixture__/writer" and request.method == "POST": values = await request.json() assert set(values) == {"action"} diff --git a/tests/containers/test_administration_image.py b/tests/containers/test_administration_image.py index 5264c96..eeb70cc 100644 --- a/tests/containers/test_administration_image.py +++ b/tests/containers/test_administration_image.py @@ -126,3 +126,155 @@ def factory(): historical, ) assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +def test_current_image_composes_private_profile_http_without_a_second_login(image, mode): + result = docker( + "run", + "--rm", + "--network", + "none", + "--user", + "65532:65532", + "--entrypoint", + "/app/.venv/bin/python", + image, + "-c", + """ +import asyncio, sys +from pathlib import Path +from tempfile import TemporaryDirectory +import httpx +from domus.composition import ingress, server +from domus.domain.identity import HumanIdentity +from domus.domain.member_profiles import MemberLocale +from domus.infrastructure.ha_http import OAuthTokens + +USER, OTHER = 'a' * 32, 'b' * 32 +ORIGIN = 'https://fictional-domus.example' +class Source: + actor = USER + def __init__(self, endpoint): + self.endpoint = endpoint + def current_user(self, tokens): + return self.directory_user('', self.actor) + def directory_user(self, token, user_id): + assert user_id in (USER, OTHER) + return HumanIdentity(user_id, True, True, False) + def directory_users(self, token, user_ids): + return tuple(self.directory_user(token, user_id) for user_id in user_ids) + def _request(self, token, operation): + assert operation == 'config/auth/list' + return [{'id': user, 'name': 'Same name', 'is_active': True, + 'system_generated': False} for user in (USER, OTHER)] + +class OAuth: + def __init__(self, configuration): + self.configuration = configuration + def exchange(self, code, verifier): + return OAuthTokens('fictional-access', 'fictional-refresh', 3600, 'homeassistant') + def revoke(self, token): + pass + +mode = sys.argv[1] +assert mode in ('native', 'standalone') +ingress.HomeAssistantWebSocket = Source +server.HomeAssistantWebSocket = Source +server.HomeAssistantOAuth = OAuth + +async def exercise(root): + if mode == 'native': + runtime = ingress.create_owned_runtime('fictional-supervisor', 'edge', root, (ORIGIN,)) + client_headers = {'X-Remote-User-Id': USER, + 'X-Ingress-Path': '/api/hassio_ingress/' + 'b' * 43, + 'Cookie': 'ingress_session=' + 'c' * 128} + else: + runtime = server.create_owned_runtime('http://fictional-ha.local', ORIGIN, 'edge', root) + client_headers = {} + try: + transport = httpx.ASGITransport(app=runtime.app, client=('172.30.32.2', 12345)) + async with httpx.AsyncClient(transport=transport, base_url=ORIGIN, + headers=client_headers) as client: + def sign_in(user): + Source.actor = user + now = runtime.identity.clock.now() + session = 'session-' + user + runtime.identity.sign_in('fixture-code', 'v' * 43, session, now, now + 3600000) + schemes = runtime.app.openapi()['components']['securitySchemes'] + cookie = schemes['DomusSession']['name'] + client.cookies.set(cookie, session) + if mode == 'standalone': + sign_in(USER) + async def headers(key): + response = await client.get('/api/v1/auth/csrf') + assert response.status_code == 200, response.text + return {'Origin': ORIGIN, 'X-CSRF-Token': response.json()['csrf_token'], + 'Idempotency-Key': key} + response = await client.post('/api/v1/household', json={ + 'expected_revision': 0, 'name': 'API fixture', 'time_zone': 'UTC'}, + headers=await headers('setup')) + assert response.status_code == 200, response.text + base = '/api/v1/administration' + capabilities = (await client.get(base + '/capabilities')).json() + assert capabilities['contract_version'] == 2 + assert capabilities['household_id'] == 'home' + assert capabilities['journal']['namespace'] == 'administration' + original = (await client.get(base + '/preferences')).json() + member = original['member_id'] + roster = await client.get(base + '/roster') + assert roster.status_code == 200, roster.text + assert roster.json()['directory_state'] == 'available' + assert roster.json()['members'][0]['metadata'] == { + 'state': 'available', 'name': 'Same name', 'avatar_state': 'unavailable'} + directory = await client.get(base + '/directory') + assert directory.status_code == 200, directory.text + assert {row['ha_user_id'] for row in directory.json()['profiles']} == {USER, OTHER} + saved = await client.put(base + '/preferences', json={ + 'expected_revision': 1, 'locale': 'es', 'routine_participation_summaries': False}, + headers=await headers('private')) + assert saved.status_code == 200, saved.text + operation = saved.json()['operation_id'] + public = await client.put(base + '/members/' + member + '/presentation', json={ + 'expected_revision': 1, 'alias': 'Literal'}, headers=await headers('public')) + assert public.status_code == 200, public.text + before = runtime.storage.database.path.read_bytes() + recovered = await client.get(base + '/requests/member.preferences.update/private') + assert recovered.status_code == 200 and recovered.json()['operation_id'] == operation + assert recovered.json()['notification_delivery'] == 'unavailable' + assert runtime.storage.database.path.read_bytes() == before + if mode == 'native': + client.headers['X-Remote-User-Id'] = OTHER + client.headers['Cookie'] = 'ingress_session=' + 'd' * 128 + else: + sign_in(OTHER) + joined = await client.post('/api/v1/members/enrollment', json={'expected_revision': 0}, + headers=await headers('join')) + assert joined.status_code == 200 and joined.json()['result']['status'] == 'active' + private_path = base + '/requests/member.preferences.update/private' + assert (await client.get(private_path)).status_code == 404 + assert (await client.get('/api/v1/operations/' + operation)).status_code == 404 + own = (await client.get(base + '/preferences')).json() + assert own['member_id'] != member and own['locale'] is None + assert own['routine_participation_summaries'] is True + roster = await client.get(base + '/roster') + assert roster.status_code == 200 and len(roster.json()['members']) == 2 + assert all(set(row['metadata']) == {'state', 'name', 'avatar_state'} + for row in roster.json()['members']) + presentation = await client.get(base + '/members/' + member + '/presentation') + assert presentation.json()['alias'] == 'Literal' + with runtime.storage.database.read() as unit: + assert unit.preferences.get('home', member).locale is MemberLocale.SPANISH + assert unit.preferences.get('home', member).revision == 2 + assert unit.presentations.get('home', member).revision == 2 + finally: + runtime.close() + +with TemporaryDirectory(prefix='domus-administration-api-image-') as directory: + root = Path(directory) / 'owned' + root.mkdir(mode=0o700) + asyncio.run(exercise(root)) +""", + mode, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/containers/test_app_ui.py b/tests/containers/test_app_ui.py index f1f1244..1803756 100644 --- a/tests/containers/test_app_ui.py +++ b/tests/containers/test_app_ui.py @@ -28,9 +28,9 @@ def test_packaged_ui_bytes_and_same_listener_standalone_delivery(image, data_vol source = Path(__file__).with_name("ui_fixture.py").read_text() expected = json.loads( ( - Path(__file__).resolve().parents[2] / "docs/evidence/household-entry-interface.json" + Path(__file__).resolve().parents[2] / "docs/evidence/household-administration.json" ).read_text() - )["productDelivery"]["actualProductFiles"] + )["currentSourcePackage"]["actualProductFiles"] try: started = docker( "run", diff --git a/tests/packaging/test_ui_wheel.py b/tests/packaging/test_ui_wheel.py index 5ff9f2a..ac6b2d9 100644 --- a/tests/packaging/test_ui_wheel.py +++ b/tests/packaging/test_ui_wheel.py @@ -1,6 +1,7 @@ """Verify the actual built wheel, including ignored UI resources and fixture exclusion.""" import hashlib +import json from pathlib import Path from zipfile import ZipFile @@ -20,6 +21,13 @@ def test_built_wheel_contains_exact_product_bundle_and_no_browser_fixture(tmp_pa if path.is_file() } assert len(expected) >= 4 + candidate = json.loads((ROOT / "docs/evidence/household-administration.json").read_text())[ + "currentSourcePackage" + ]["actualProductFiles"] + assert candidate == { + name.removeprefix("domus/ui/"): hashlib.sha256(data).hexdigest() + for name, data in expected.items() + } with ZipFile(wheels[0]) as wheel: names = wheel.namelist() assert len(names) == len(set(names))