,clearAlias=false,activationRequired=false,first:string;
+ switch(action){
+ case 'household.update':{
+ const home=view.household;if(home===null)return;
+ payload={expected_revision:home.revision,name:home.name,time_zone:home.timeZone};first='name';break;
+ }
+ case 'member.preferences.update':{
+ const value=view.preferences;if(value===null)return;
+ payload={expected_revision:value.revision,locale:value.locale??'',routine_participation_summaries:value.routine_participation_summaries};first='locale';break;
+ }
+ case 'member.presentation.update':{
+ const value=memberId===view.access!.member!.memberId?this.ownPresentation(view):view.roster?.members.find(row=>row.member.memberId===memberId)?.presentation;
+ if(!value)return;
+ payload={expected_revision:value.revision,alias:value.alias??''};clearAlias=value.alias===null;first='clear';break;
+ }
+ case 'member.update':{
+ const value=view.roster?.members.find(row=>row.member.memberId===memberId)?.member;if(!value||value.exceptions===null)return;
+ payload={expected_revision:value.revision,role:value.status==='pending'?'restricted':value.role,status:value.status,exceptions:[...value.exceptions]};
+ activationRequired=value.status!=='active';first='role';break;
+ }
+ case 'policy.update':{
+ const value=view.policy;if(value===null)return;
+ payload={expected_revision:value.revision,mapping_mode:value.mappingMode,target_ha_user_id:value.targetHAUserId,adjustments:[...value.adjustments]};first='mapping_mode';break;
+ }
+ }
+ const key=action+':'+(memberId??'own');
+ const draft:Draft={key,owner,action,...memberId===undefined?{}:{memberId},payload,clearAlias,editRole:'adult',activationRequired,
+ approved:false,review:null,error:null,sent:false};
+ if(!this.enabled(draft))return;
+ this.drafts.set(key,draft);this.focusAfter=this.fieldId(draft,first);this.requestUpdate();
+ }
+ private fieldId(draft:Draft,field:string):string{return 'draft-'+draft.key+'-'+field;}
+ private change(draft:Draft,key:string,value:unknown):void {
+ if(!this.current(draft)||!this.enabled(draft)||draft.sent)return;
+ draft.payload={...draft.payload,[key]:value};draft.error=null;this.requestUpdate();
+ }
+ private check(draft:Draft,key:'clearAlias'|'approved',value:boolean):void {
+ if(!this.current(draft)||!this.enabled(draft)||draft.sent)return;
+ draft[key]=value;draft.error=null;this.requestUpdate();
+ }
+ private review(draft:Draft):void {
+ if(!this.current(draft)||!this.enabled(draft)||draft.sent)return;
+ if(draft.activationRequired&&draft.payload.status==='active'&&!draft.approved){draft.error='activationRequired';}
+ else try {
+ const payload={...draft.payload};
+ if(draft.action==='member.presentation.update')payload.alias=draft.clearAlias?null:payload.alias;
+ if(draft.action==='member.preferences.update'&&payload.locale==='')payload.locale=null;
+ if(draft.action==='policy.update'&&payload.mapping_mode==='all_ha_administrators')payload.target_ha_user_id=null;
+ draft.review=administrationCommand({action:draft.action,payload,...draft.memberId===undefined?{}:{memberId:draft.memberId}});
+ draft.error=null;
+ } catch {draft.error='invalid';}
+ this.focusAfter=this.fieldId(draft,draft.error===null?'review':'error');this.requestUpdate();
+ }
+ private save(draft:Draft):void {
+ if(!this.current(draft)||!this.enabled(draft)||draft.sent||draft.review===null)return;
+ draft.sent=true;this.intent({type:'submit',command:draft.review});this.requestUpdate();
+ }
+ private close(draft:Draft):void {
+ if(!this.current(draft))return;
+ this.drafts.delete(draft.key);this.focusAfter='open-'+draft.key;this.requestUpdate();
+ }
+ private reset(draft:Draft):void {
+ if(this.current(draft)&&this.enabled(draft))this.open(draft.action,draft.memberId);
+ }
+ private field(draft:Draft,key:string,label:string,disabled=false) {
+ const id=this.fieldId(draft,key);
+ return html`this.change(draft,key,(event.target as HTMLInputElement).value)}>
`;
+ }
+ private select(draft:Draft,key:string,label:string,options:readonly Option[],disabled=false) {
+ const id=this.fieldId(draft,key);
+ return html``;
+ }
+ private checkbox(draft:Draft,key:'clearAlias'|'approved'|'routine_participation_summaries',label:string) {
+ const id=this.fieldId(draft,key==='clearAlias'?'clear':key),value=key==='routine_participation_summaries'?draft.payload[key]===true:draft[key];
+ return html`{
+ const checked=(event.target as HTMLInputElement).checked;
+ if(key==='routine_participation_summaries')this.change(draft,key,checked);else this.check(draft,key,checked);
+ }}>
`;
+ }
+ private ruleChange(draft:Draft,permission:string,value:string):void {
+ if(!this.current(draft)||!this.enabled(draft)||draft.sent)return;
+ if(!['inherit','allow','deny'].includes(value)){draft.error='invalid';this.requestUpdate();return;}
+ const profile=draft.action==='policy.update',key=profile?'adjustments':'exceptions';
+ const prior=draft.payload[key] as readonly (PermissionDecision|ProfileDecision)[];
+ const rules=prior.filter(rule=>rule.permission!==permission||(profile&&(rule as ProfileDecision).role!==draft.editRole));
+ if(value!=='inherit')rules.push(profile?{role:draft.editRole,permission,allow:value==='allow'}:{permission,allow:value==='allow'});
+ this.change(draft,key,rules);
+ }
+ private rules(draft:Draft) {
+ const profile=draft.action==='policy.update',rules=draft.payload[profile?'adjustments':'exceptions'] as readonly (PermissionDecision|ProfileDecision)[];
+ return html``;
+ }
+ private fields(draft:Draft) {
+ switch(draft.action){
+ case 'household.update':return html`${this.field(draft,'name','name')}${this.field(draft,'time_zone','zone')}${this.t('zoneHelp')}
`;
+ case 'member.presentation.update':return html`${this.checkbox(draft,'clearAlias','useHA')}${this.field(draft,'alias','alias',draft.clearAlias)}`;
+ case 'member.preferences.update':return html`${this.select(draft,'locale','savedLanguage',[
+ {value:'',label:this.t('browserLanguage')},{value:'en',label:this.t('english')},{value:'es',label:this.t('spanish')}])}
+ ${this.checkbox(draft,'routine_participation_summaries','summaries')}${this.t('delivery')}
`;
+ case 'member.update':return html`${this.select(draft,'role','memberRole',MEMBER_ROLES.map(value=>({value,label:this.t('role_'+value)})))}
+ ${this.select(draft,'status','status',MEMBER_STATUSES.map(value=>({value,label:this.t('status_'+value)})))}
+ ${draft.activationRequired&&draft.payload.status==='active'?this.checkbox(draft,'approved','activation'):nothing}${this.rules(draft)}`;
+ case 'policy.update':{
+ const view=this.ready()!,directory=view.directory;
+ const targets=directory?.state==='available'?directory.profiles.filter(profile=>profile.haUserId===view.session!.context.subject
+ ||view.roster?.members.some(row=>row.member.status==='active'&&row.member.haUserId===profile.haUserId)):[];
+ const available=targets.length>0;
+ const options=MAPPING_MODES.map(value=>({value,label:this.t('mapping_'+value),disabled:value!=='all_ha_administrators'&&!available&&value!==draft.payload.mapping_mode}));
+ const targetOptions:Option[]=[{value:'',label:this.t('chooseTarget')},...targets.map(profile=>({value:profile.haUserId,
+ label:(profile.name??this.t('unnamed'))+' · '+profile.haUserId.slice(-6)}))];
+ const saved=draft.payload.target_ha_user_id;
+ if(typeof saved==='string'&&saved!==''&&!targets.some(profile=>profile.haUserId===saved))
+ targetOptions.push({value:saved,label:this.t('savedTarget')+' · '+saved.slice(-6),disabled:true});
+ const id=this.fieldId(draft,'editRole');
+ return html`${this.select(draft,'mapping_mode','mapping',options)}${this.t('mappingHelp')}
+ ${draft.payload.mapping_mode!=='all_ha_administrators'?this.select(draft,'target_ha_user_id','target',targetOptions,!available):nothing}
+ ${!available?html`${this.t('directoryUnavailable')}
`:nothing}
+ ${this.rules(draft)}`;
+ }
+ }
+ }
+ private ruleSummary(rules:readonly PermissionDecision[]|readonly ProfileDecision[]) {
+ return rules.length?html`${rules.map(rule=>html`- ${'role'in rule?this.t('role_'+rule.role)+' · ':nothing}${this.t('permission_'+rule.permission)}: ${this.t(rule.allow?'allow':'deny')}
`)}
`
+ :html`${this.t('currentRules')}
`;
+ }
+ private summary(command:AdministrationCommand) {
+ switch(command.action){
+ case 'household.update':return html`- ${this.t('name')}
- ${command.payload.name}
- ${this.t('zone')}
- ${command.payload.time_zone}
`;
+ case 'member.presentation.update':return html`${this.t('alias')}: ${command.payload.alias??this.t('useHA')}
`;
+ case 'member.preferences.update':return html`${this.t('savedLanguage')}: ${this.t(command.payload.locale==='en'?'english':command.payload.locale==='es'?'spanish':'browserLanguage')}
+ ${this.t('summaries')}: ${this.t(command.payload.routine_participation_summaries?'allow':'deny')}
${this.t('delivery')}
`;
+ case 'member.update':return html`${this.t('memberRole')}: ${this.t('role_'+command.payload.role)}
${this.t('status')}: ${this.t('status_'+command.payload.status)}
${this.ruleSummary(command.payload.exceptions)}`;
+ case 'policy.update':return html`${this.t('mapping')}: ${this.t('mapping_'+command.payload.mapping_mode)}
+ ${command.payload.target_ha_user_id===null?nothing:html`${this.t('target')}: …${command.payload.target_ha_user_id.slice(-6)}
`}${this.ruleSummary(command.payload.adjustments)}`;
+ }
+ }
+ private readable(draft:Draft,view:AdministrationView):boolean {
+ switch(draft.action){
+ case 'household.update':return view.household!==null;
+ case 'policy.update':return view.policy!==null;
+ case 'member.preferences.update':return view.preferences!==null;
+ case 'member.presentation.update':return draft.memberId===view.access!.member!.memberId
+ ?this.ownPresentation(view)!==null:view.roster?.members.some(row=>row.member.memberId===draft.memberId)===true;
+ case 'member.update':return view.roster?.members.some(row=>row.member.memberId===draft.memberId&&row.member.exceptions!==null)===true;
+ }
+ }
+ private editor(draft:Draft,view:AdministrationView) {
+ if(!this.readable(draft,view))return html`
+ ${this.actionTitle(draft.action)}
${this.t('hiddenDraft')}
+ `;
+ const enabled=this.enabled(draft);
+ const row=view.roster?.members.find(row=>row.member.memberId===draft.memberId);
+ const target=draft.memberId===undefined?null:row?this.display(row):this.ownPresentation(view)!.alias??this.ownName(view);
+ return html`${this.actionTitle(draft.action)}
+ ${target===null?nothing:html`${this.t('participant')}: ${target}
+ ${this.t('memberReference')}: ${draft.memberId}
`}
+ ${this.t('revision')}: ${String(draft.payload.expected_revision)}
+ ${draft.error===null?nothing:html`${this.t(draft.error)}
`}
+ ${!enabled?html`${this.t('locked')}
`:nothing}
+ ${draft.review===null?html``
+ :html`${this.t('reviewTitle')}
${this.summary(draft.review)}
+ ${draft.sent?html`${this.t('submitted')}
`:nothing}
+
+
+
`}
+ `;
+ }
+ private openButton(label:string,action:AdministrationAction,enabled:boolean,memberId?:string,display?:string) {
+ return html``;
+ }
+ private display(row:RosterMember):string{return row.presentation.alias??row.metadata.name??this.t('unnamed');}
+ private participants(view:AdministrationView) {
+ const roster=view.roster;
+ return html`${this.t('participants')}
+ ${roster===null?html`${this.t('unavailable')}
`:html`${repeat(roster.members,row=>row.member.memberId,row=>html`-
+ ${this.display(row)}
${this.t('haName')}: ${row.metadata.name??this.t('metadata_'+row.metadata.state)}
+ ${this.t('storedProfile')}: ${this.t('role_'+row.member.role)} · ${this.t('status_'+row.member.status)}
+ ${this.t('avatar')}
+ ${this.openButton('manage','member.update',view.controls.memberIds.includes(row.member.memberId),row.member.memberId,this.display(row))}
+ ${row.member.memberId===view.access!.member!.memberId?nothing:this.openButton('otherAlias','member.presentation.update',view.controls.aliasMemberIds.includes(row.member.memberId),row.member.memberId,this.display(row))}
+
`)}
`}`;
+ }
+ private outcome(view:AdministrationView) {
+ const result=view.outcome;if(result===null)return nothing;
+ const kind=result.kind==='receipt'?result.stage:result.kind;
+ return html`${this.t(kind)}
${this.t(kind+'Body')}
+ ${result.kind==='receipt'?html`${this.t('operation')}: ${result.operationId}
`:nothing}
+ ${result.kind==='rejected'?html`${this.t('notice_'+result.reason)}
${result.correlationId===null?nothing:html`${this.t('correlation')}: ${result.correlationId}
`}`:nothing}`;
+ }
+ private requests(view:AdministrationView) {
+ const records=view.journal?.records;if(!records?.length)return nothing;
+ return html`${this.t('requests')}
${this.t('requestHelp')}
+ ${records.map(record=>html`- ${this.actionTitle(record.action)}
${this.t('stage_'+record.stage)}
+ ${this.t('deadline')}: ${instant(record.expiresAt,this.language)}
+ ${record.operationId===null?nothing:html`${this.t('operation')}: ${record.operationId}
`}
+
+
`)}
`;
+ }
+ private original(type:'recover'|'dismiss',key:string):void {
+ const view=this.ready();if(view===null)return;
+ if((type==='recover'?view.controls.recoverableKeys:view.controls.dismissibleKeys).includes(key))this.intent({type,key});
+ }
+ protected render() {
+ const view=this.ready();
+ if(view===null){
+ if(this.pending||this.view?.busy||this.view?.phase==='loading')return html`${this.t('checking')}
${this.t('checkingBody')}
`;
+ if(this.view===null||this.retired)return nothing;
+ return html`${this.t('title')}
${this.t('notice_'+(this.view.notice??'unavailable'))}
+ `;
+ }
+ const person=view.access!.member!,own=this.ownPresentation(view);
+ const haName=this.ownName(view);
+ return html`${this.t('title')}
+ ${view.notice===null?nothing:html`${this.t('notice_'+view.notice)}
`}${this.outcome(view)}
+ ${this.t('ownProfile')}
${own?.alias??haName}
${this.t('haName')}: ${haName}
+ ${this.t('storedProfile')}: ${this.t('role_'+person.role)}
${this.t('effectiveProfile')}: ${this.t('role_'+view.access!.effectiveRole)}
+ ${this.t('status')}: ${this.t('status_'+person.status)}
${this.t('avatar')}
+ ${this.openButton('editAlias','member.presentation.update',view.controls.ownAlias,person.memberId)}
+ ${this.openButton('editPreferences','member.preferences.update',view.controls.preferences)}
+ ${this.participants(view)}${this.t('household')}
${this.openButton('editHousehold','household.update',view.controls.household)}
+ ${this.t('policy')}
${this.t('protected')}
${this.openButton('editPolicy','policy.update',view.controls.policy)}
+ ${[...this.drafts.values()].map(draft=>this.editor(draft,view))}${this.requests(view)}
+ `;
+ }
+}
+customElements.define('domus-administration',DomusAdministration);
+declare global {interface HTMLElementTagNameMap {'domus-administration':DomusAdministration;}}
diff --git a/frontend/src/presentation/domus-app.ts b/frontend/src/presentation/domus-app.ts
index 1923dbb..03c469c 100644
--- a/frontend/src/presentation/domus-app.ts
+++ b/frontend/src/presentation/domus-app.ts
@@ -2,6 +2,8 @@ import {LitElement, html, nothing} from 'lit';
import {live} from 'lit/directives/live.js';
import type {PropertyValues} from 'lit';
import type {EntryView} from '../application/entry-controller';
+import type {AdministrationView} from '../application/administration-controller';
+import './domus-administration';
import type {Deployment} from '../domain/entry';
import type {JournalRecord} from '../domain/journal';
import {instant, language, message} from './messages';
@@ -15,16 +17,17 @@ export type EntryIntent = Readonly<{type: 'refresh' | 'request_membership' | 'lo
/** Owned rendering only; intents cross a semantic boundary and never grant command authority. */
export class DomusApp extends LitElement {
- static properties = {view: {attribute: false}, deployment: {attribute: false}, language: {attribute: false},
+ static properties = {view: {attribute: false}, administration: {attribute:false}, deployment: {attribute: false}, language: {attribute: false},
nameDraft: {state: true}, zoneDraft: {state: true}};
static styles = styles;
declare view: EntryView | null;
+ declare administration:AdministrationView|null;
declare deployment: Deployment;
declare language: Language;
private declare nameDraft: string;
private declare zoneDraft: string;
private draftScope: string | null = null;
- constructor() {super(); this.view = null; this.deployment = 'standalone'; this.language = 'en'; this.nameDraft = ''; this.zoneDraft = '';}
+ constructor() {super(); this.view = null; this.administration=null; this.deployment = 'standalone'; this.language = 'en'; this.nameDraft = ''; this.zoneDraft = '';}
disconnectedCallback(): void {super.disconnectedCallback(); this.dispatchEvent(new Event('domus-detached'));}
private text(key: MessageKey): string {return message(key, this.language);}
private intent(detail: EntryIntent): void {
@@ -117,6 +120,9 @@ export class DomusApp extends LitElement {
${!loading && decision?.action === 'contact_administrator' ? html`${this.text('contact')}
` : nothing}
${!closed && view?.busy ? html`${this.text('busy')}
` : nothing}
+
${view?.journal?.records.length ? html`${this.text('journalTitle')}
${this.text('journalBody')}
${view.journal.records.map(record => this.request(record))}
` : nothing}
${view?.session ? html`${this.text('details')}
- ${this.text('status')}
- ${this.text(`state_${state}`)}
diff --git a/frontend/stories/administration.stories.ts b/frontend/stories/administration.stories.ts
new file mode 100644
index 0000000..8a3c7a2
--- /dev/null
+++ b/frontend/stories/administration.stories.ts
@@ -0,0 +1,51 @@
+import {html} from 'lit';
+import type {Meta,StoryObj} from '@storybook/web-components-vite';
+import type {AdministrationView} from '../src/application/administration-controller';
+import {revisionValue} from '../src/domain/administration';
+import type {Language} from '../src/presentation/messages';
+import '../src/presentation/domus-administration';
+
+type Example='active'|'read-only'|'sources-unavailable'|'uncertain'|'historical';
+type Arguments={example:Example;language:Language;deployment:'native'|'standalone'};
+const time=1_800_000_000_000;
+const context={installationId:'01234567-89ab-cdef-0123-456789abcdef',channel:'edge' as const,subject:'a'.repeat(32)};
+/** Fictional view data only; actions emit intents and never manufacture a server result. */
+function fixture(example:Example,deployment:Arguments['deployment']):AdministrationView {
+ const readOnly=example==='read-only',unavailable=example==='sources-unavailable';
+ const member={memberId:'fictional-owner',haUserId:context.subject,role:'adult' as const,status:'active' as const,revision:revisionValue('3'),exceptions:[]};
+ const presentation={memberId:member.memberId,alias:null,revision:revisionValue('11')};
+ const journaled=example==='uncertain'||example==='historical';
+ const stage=example==='historical'?'historical' as const:'uncertain' as const;
+ return {phase:'ready',busy:false,session:{context,deployment,access:readOnly?'read_only':'full',serverTime:time,expiresAt:time+900_000,householdApiAvailable:true},
+ access:{state:'active',setupAllowed:false,enrollmentAllowed:false,member,effectiveRole:'administrator',policyRevision:revisionValue('5'),
+ permissions:['household.read','household.manage','members.read','members.manage','policy.manage','preferences.read','preferences.write','operations.read']},
+ capabilities:{contractVersion:2,householdId:'fictional-home',actions:['household.update','member.update','policy.update','member.presentation.update','member.preferences.update']},
+ household:{householdId:'fictional-home',name:'Maple House',timeZone:'Europe/Madrid',revision:revisionValue('7')},
+ policy:{householdId:'fictional-home',mappingMode:'all_ha_administrators',targetHAUserId:null,adjustments:[],revision:revisionValue('5')},
+ directory:unavailable?{state:'denied',profiles:[]}:{state:'available',profiles:[{haUserId:context.subject,name:'Fictional owner'}]},
+ roster:{directoryState:unavailable?'denied':'available',members:[{member,presentation,
+ metadata:{state:unavailable?'denied':'available',name:unavailable?null:'Fictional owner',avatarState:'unavailable'}},
+ {member:{...member,memberId:'fictional-pending',haUserId:'b'.repeat(32),role:'restricted',status:'pending'},
+ presentation:{memberId:'fictional-pending',alias:null,revision:revisionValue('1')},
+ metadata:{state:'available',name:'Fictional pending participant',avatarState:'unavailable'}}]},
+ presentation,preferences:{memberId:member.memberId,locale:null,routine_participation_summaries:true,revision:revisionValue('13'),notificationDelivery:'unavailable'},
+ errors:{},notice:readOnly?'read_only':null,
+ journal:{context,observedAt:time,records:journaled?[{action:'member.preferences.update',payload:{expected_revision:revisionValue('12'),locale:null,routine_participation_summaries:true},
+ context,key:'fictional-original',createdAt:time,expiresAt:time+3_600_000,stage,operationId:example==='historical'?'op_fictional':null}]:[]},
+ outcome:example==='uncertain'?{kind:'uncertain'}:example==='historical'?{kind:'receipt',stage:'historical',action:'member.preferences.update',operationId:'op_fictional',
+ archivedBy:'restore_fictional',createdAt:time,result:{memberId:member.memberId,locale:null,routine_participation_summaries:true,notificationDelivery:'unavailable',revision:revisionValue('13')},notificationDelivery:'unavailable',eventDelivery:'suppressed'}:null,
+ controls:{household:!readOnly,policy:!readOnly,preferences:!readOnly&&example!=='uncertain',ownAlias:!readOnly,memberIds:readOnly?[]:['fictional-owner','fictional-pending'],
+ aliasMemberIds:readOnly?[]:['fictional-pending'],recoverableKeys:journaled?['fictional-original']:[],dismissibleKeys:example==='historical'?['fictional-original']:[]}};
+}
+const meta:Meta={title:'Domus/Household administration',args:{example:'active',language:'en',deployment:'native'},
+ argTypes:{language:{control:'inline-radio',options:['en','es']},deployment:{control:'inline-radio',options:['native','standalone']},example:{control:false}},
+ render:args=>html`
+ Fictional administration state. No server is connected; actions emit intents only.
+ `};
+export default meta;
+type Story=StoryObj;
+export const Active:Story={};
+export const ReadOnly:Story={args:{example:'read-only'}};
+export const SourcesUnavailable:Story={args:{example:'sources-unavailable'}};
+export const Uncertain:Story={args:{example:'uncertain'}};
+export const Historical:Story={args:{example:'historical'}};
diff --git a/frontend/tests/administration-component.test.ts b/frontend/tests/administration-component.test.ts
new file mode 100644
index 0000000..71818f8
--- /dev/null
+++ b/frontend/tests/administration-component.test.ts
@@ -0,0 +1,350 @@
+import {afterEach, expect, test} from 'vitest';
+import {DomusAdministration} from '../src/presentation/domus-administration';
+import type {AdministrationIntent} from '../src/presentation/domus-administration';
+import type {AdministrationView} from '../src/application/administration-controller';
+import {revisionValue} from '../src/domain/administration';
+import {administrationMessage} from '../src/presentation/administration-messages';
+
+const context = {installationId:'01234567-89ab-cdef-0123-456789abcdef',channel:'edge' as const,subject:'a'.repeat(32)};
+const member = {memberId:'owner',haUserId:context.subject,role:'viewer' as const,status:'active' as const,
+ revision:revisionValue('3'),exceptions:[]};
+const presentation = {memberId:'owner',alias:'My alias',revision:revisionValue('11')};
+function view(extra: Partial = {}): AdministrationView {
+ return {phase:'ready',busy:false,session:{context,deployment:'standalone',access:'full',serverTime:1000,
+ expiresAt:100000,householdApiAvailable:true},access:{state:'active',setupAllowed:false,enrollmentAllowed:false,
+ member,effectiveRole:'administrator',policyRevision:revisionValue('5'),permissions:['household.read','household.manage',
+ 'members.read','members.manage','policy.manage','preferences.read','preferences.write','operations.read']},
+ capabilities:{contractVersion:2,householdId:'home',actions:['household.update','member.update','policy.update','member.presentation.update','member.preferences.update']},
+ household:{householdId:'home',name:'Household',timeZone:'UTC',revision:revisionValue('7')},
+ policy:{householdId:'home',revision:revisionValue('5'),mappingMode:'all_ha_administrators',targetHAUserId:null,
+ adjustments:[{role:'restricted',permission:'shopping.request',allow:false}]},
+ directory:{state:'available',profiles:[{haUserId:context.subject,name:'Verified owner'},{haUserId:'b'.repeat(32),name:'Pending member'}]},
+ presentation,preferences:{memberId:'owner',locale:'es',routine_participation_summaries:true,revision:revisionValue('13'),notificationDelivery:'unavailable'},
+ roster:{directoryState:'available',members:[{member,presentation,metadata:{state:'available',name:'Verified owner',avatarState:'unavailable'}},
+ {member:{...member,memberId:'other',haUserId:'b'.repeat(32),status:'pending'},presentation:{...presentation,memberId:'other',alias:null},
+ metadata:{state:'available',name:'Pending member',avatarState:'unavailable'}}]},
+ errors:{},notice:null,outcome:null,journal:{context,observedAt:1000,records:[]},
+ controls:{household:true,policy:true,preferences:true,ownAlias:true,memberIds:['owner','other'],aliasMemberIds:['other'],recoverableKeys:[],dismissibleKeys:[]},...extra};
+}
+const owned: DomusAdministration[] = [];
+afterEach(()=>{for(const element of owned.splice(0)) element.remove();});
+async function mount(value: AdministrationView|null = view(), locale:'en'|'es'='en') {
+ const element = document.createElement('domus-administration'); element.view=value; element.language=locale;
+ const intents:AdministrationIntent[]=[];
+ element.addEventListener('domus-administration-intent',event=>{
+ const actual=event as CustomEvent;expect(actual.bubbles).toBe(true);expect(actual.composed).toBe(true);
+ expect(Object.isFrozen(actual.detail)).toBe(true);intents.push(actual.detail);
+ });
+ document.body.append(element);owned.push(element);await element.updateComplete;const root=element.shadowRoot!;
+ const button=(name:string)=>[...root.querySelectorAll('button')].find(item=>(item.getAttribute('aria-label')??item.textContent?.trim())===name)!;
+ const click=async(name:string)=>{expect(button(name),name).toBeDefined();button(name).click();await element.updateComplete;};
+ const input=(name:string)=>{
+ const label=[...root.querySelectorAll('label')].find(item=>item.textContent?.trim()===name)!;expect(label,name).toBeDefined();
+ return root.getElementById(label.htmlFor) as HTMLInputElement|HTMLSelectElement;
+ };
+ const change=async(name:string,value:string|boolean)=>{
+ const field=input(name);if(typeof value==='boolean'){expect(field).toBeInstanceOf(HTMLInputElement);(field as HTMLInputElement).checked=value;}
+ else field.value=value;
+ field.dispatchEvent(new Event(field instanceof HTMLSelectElement||field.type==='checkbox'?'change':'input',{bubbles:true}));await element.updateComplete;
+ };
+ return {element,root,intents,button,click,input,change,text:()=>root.textContent!};
+}
+
+test('HADB-03: the owned widget shows current stored and effective roles without emitting work',async()=>{
+ const f=await mount();expect(f.element).toBeInstanceOf(DomusAdministration);expect(f.text()).toContain('Verified owner');
+ expect(f.text()).toContain('Viewer');expect(f.text()).toContain('Administrator');expect(f.text()).toContain('Pending member');
+ expect(f.text()).not.toContain(context.installationId);expect(f.text()).not.toContain(context.subject);expect(f.intents).toEqual([]);
+});
+test('household-browser-C15: hostile HA names and aliases remain inert text',async()=>{
+ const current=view();const hostile='
';
+ const f=await mount({...current,presentation:{...presentation,alias:hostile},roster:{...current.roster!,members:[
+ {...current.roster!.members[0],presentation:{...presentation,alias:hostile},metadata:{state:'available',name:hostile,avatarState:'unavailable'}}]}});
+ expect(f.text()).toContain(hostile);expect(f.root.querySelector('img,script')).toBeNull();expect(f.intents).toEqual([]);
+});
+test('HADW-01: own alias requires review then explicit confirmation with its independent exact revision',async()=>{
+ const f=await mount(view({presentation:{...presentation,revision:revisionValue('9007199254740993')}}));
+ await f.click('Edit own alias');await f.change('Domus alias',' Literal name ');await f.click('Review changes');
+ expect(f.intents).toEqual([]);expect(f.text()).toContain('9007199254740993');await f.click('Save changes');
+ expect(f.intents).toEqual([{type:'submit',command:{action:'member.presentation.update',memberId:'owner',
+ payload:{expected_revision:'9007199254740993',alias:' Literal name '}}}]);
+ expect(f.button('Save changes').disabled).toBe(true);expect(f.root.querySelector('b')).toBeNull();
+});
+test('HADW-01: clearing an alias requires the explicit HA-name choice rather than an empty value',async()=>{
+ const f=await mount();await f.click('Edit own alias');await f.change('Domus alias','');await f.click('Review changes');
+ expect(f.root.querySelector('[role=alert]')).not.toBeNull();expect(f.intents).toEqual([]);
+ await f.change('Use Home Assistant name',true);await f.click('Review changes');await f.click('Save changes');
+ expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{expected_revision:'11',alias:null}}});
+});
+test('HADW-02: own preference values have their own revision and honest unavailable delivery',async()=>{
+ const f=await mount();await f.click('Edit preferences');await f.change('Saved language','en');
+ await f.change('Routine participation summaries',false);await f.click('Review changes');await f.click('Save changes');
+ expect(f.intents).toEqual([{type:'submit',command:{action:'member.preferences.update',payload:{expected_revision:'13',locale:'en',routine_participation_summaries:false}}}]);
+ expect(f.text()).toContain('Notification delivery is unavailable');
+});
+test('HADW-04: household edits retain the original revision and exact timezone until explicit submission',async()=>{
+ const f=await mount();await f.click('Edit household');await f.change('New household name','Family');await f.change('New timezone','Europe/Madrid');
+ await f.click('Review changes');await f.click('Save changes');expect(f.intents).toEqual([{type:'submit',command:{action:'household.update',
+ payload:{expected_revision:'7',name:'Family',time_zone:'Europe/Madrid'}}}]);
+});
+test('HADW-04: pending activation defaults to restricted and needs an explicit access confirmation',async()=>{
+ const f=await mount();await f.click('Manage participant: Pending member');expect(f.input('Household profile').value).toBe('restricted');
+ await f.change('Membership status','active');await f.click('Review changes');expect(f.intents).toEqual([]);
+ expect(f.root.querySelector('[role=alert]')).not.toBeNull();await f.change('Approve household access',true);
+ await f.click('Review changes');await f.click('Save changes');expect(f.intents).toEqual([{type:'submit',command:{action:'member.update',memberId:'other',
+ payload:{expected_revision:'3',role:'restricted',status:'active',exceptions:[]}}}]);
+});
+test('HADW-05: a profile adjustment preserves the untouched mapping and other profile rules',async()=>{
+ const f=await mount();await f.click('Edit access rules');await f.change('Profile to customize','adult');
+ await f.change('View household','deny');await f.click('Review changes');await f.click('Save changes');
+ const intent=f.intents[0];expect(intent.type).toBe('submit');if(intent.type!=='submit')throw new Error('Expected explicit policy intent');
+ expect(intent.command).toMatchObject({action:'policy.update',payload:{expected_revision:'5',mapping_mode:'all_ha_administrators',target_ha_user_id:null}});
+ if(intent.command.action!=='policy.update')throw new Error('Expected policy action');
+ expect(intent.command.payload.adjustments).toHaveLength(2);expect(intent.command.payload.adjustments).toEqual(expect.arrayContaining([
+ {role:'restricted',permission:'shopping.request',allow:false},{role:'adult',permission:'household.read',allow:false}]));
+});
+test('household-browser-C17: translation and a newer current fact do not rebase a safe alias draft',async()=>{
+ const f=await mount();await f.click('Edit own alias');await f.change('Domus alias','Local draft');
+ f.element.language='es';f.element.view=view({presentation:{...presentation,alias:'Remote value',revision:revisionValue('12')}});await f.element.updateComplete;
+ expect(f.input('Alias de Domus').value).toBe('Local draft');await f.click('Revisar cambios');await f.click('Guardar cambios');
+ expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{expected_revision:'11',alias:'Local draft'}}});
+});
+test('household-browser-C13: same-account member replacement cannot retain the former private preference draft',async()=>{
+ const f=await mount();await f.click('Edit preferences');await f.change('Saved language','en');
+ const next=view();f.element.view={...next,access:{...next.access!,member:{...member,memberId:'replacement'}},
+ presentation:{...presentation,memberId:'replacement'},preferences:{...next.preferences!,memberId:'replacement'}};
+ await f.element.updateComplete;await f.click('Edit preferences');expect(f.input('Saved language').value).toBe('es');expect(f.intents).toEqual([]);
+});
+test('HADB-01: checking hides private form values and preserves only a same-owner draft until proof returns',async()=>{
+ const current=view(),f=await mount(current);await f.click('Edit own alias');await f.change('Domus alias','Unsent private draft');
+ f.element.pending=true;f.element.view=null;await f.element.updateComplete;expect(f.text()).not.toContain('Unsent private draft');
+ expect(f.root.querySelector('input')).toBeNull();f.element.pending=false;f.element.view=current;await f.element.updateComplete;
+ expect(f.input('Domus alias').value).toBe('Unsent private draft');f.element.retired=true;f.element.view=null;await f.element.updateComplete;
+ f.element.retired=false;f.element.view=current;await f.element.updateComplete;await f.click('Edit own alias');
+ expect(f.input('Domus alias').value).toBe('My alias');expect(f.intents).toEqual([]);
+});
+test('HADB-03: application controls govern visible edits even when an old permission label still appears',async()=>{
+ const current=view(),f=await mount({...current,controls:{...current.controls,household:false,policy:false,preferences:false,ownAlias:false,memberIds:[],aliasMemberIds:[]}});
+ expect(f.button('Edit own alias').disabled).toBe(true);expect(f.button('Edit preferences').disabled).toBe(true);
+ f.button('Edit preferences').click();expect(f.root.querySelector('form')).toBeNull();expect(f.intents).toEqual([]);
+});
+test('HADW-05: an HA account with pending household membership is not offered as a new mapping target',async()=>{
+ const f=await mount();await f.click('Edit access rules');await f.change('HA administrator mapping','selected_ha_user');
+ const options=[...(f.input('Target Home Assistant account') as HTMLSelectElement).options];
+ expect(options.some(option=>option.value===context.subject)).toBe(true);expect(options.some(option=>option.value==='b'.repeat(32))).toBe(false);
+});
+test('HADW-05: unavailable directory facts retain the stored target visibly while other profile rules remain editable',async()=>{
+ const current=view();const f=await mount({...current,directory:{state:'denied',profiles:[]},policy:{...current.policy!,mappingMode:'selected_ha_user',targetHAUserId:context.subject}});
+ await f.click('Edit access rules');const target=f.input('Target Home Assistant account');expect(target.value).toBe(context.subject);expect(target.disabled).toBe(true);
+ await f.change('View household','allow');await f.click('Review changes');await f.click('Save changes');
+ expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{mapping_mode:'selected_ha_user',target_ha_user_id:context.subject}}});
+});
+test('HADW-05: explicitly switching to all HA administrators clears the formerly captured target',async()=>{
+ const current=view(),f=await mount({...current,policy:{...current.policy!,mappingMode:'selected_ha_user',targetHAUserId:context.subject}});
+ await f.click('Edit access rules');await f.change('HA administrator mapping','all_ha_administrators');await f.click('Review changes');await f.click('Save changes');
+ expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{mapping_mode:'all_ha_administrators',target_ha_user_id:null}}});
+});
+test('HADW-05: an explicitly selected active participant remains a distinct target rather than a display-name identity',async()=>{
+ const current=view(),other=current.roster!.members[1];const f=await mount({...current,roster:{...current.roster!,members:[current.roster!.members[0],{...other,member:{...other.member,status:'active'}}]}});
+ await f.click('Edit access rules');await f.change('HA administrator mapping','selected_ha_user');await f.change('Target Home Assistant account','b'.repeat(32));
+ await f.click('Review changes');await f.click('Save changes');expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{target_ha_user_id:'b'.repeat(32)}}});
+});
+
+const draftFactCases = [
+ {kind:'household', open:'Edit household', field:'New household name', value:'Private household draft',
+ missing:(current:AdministrationView):AdministrationView=>({...current,household:null,controls:{...current.controls,household:false}})},
+ {kind:'preferences', open:'Edit preferences', field:'Saved language', value:'en',
+ missing:(current:AdministrationView):AdministrationView=>({...current,preferences:null,controls:{...current.controls,preferences:false}})},
+ {kind:'own alias', open:'Edit own alias', field:'Domus alias', value:'Private own alias draft',
+ missing:(current:AdministrationView):AdministrationView=>({...current,presentation:null,roster:null,controls:{...current.controls,ownAlias:false,memberIds:[],aliasMemberIds:[]}})},
+ {kind:'other alias', open:'Change alias: Pending member', field:'Domus alias', value:'Private other alias draft',
+ missing:(current:AdministrationView):AdministrationView=>({...current,roster:null,controls:{...current.controls,memberIds:[],aliasMemberIds:[]}})},
+ {kind:'member', open:'Manage participant: Pending member', field:'Household profile', value:'adult',
+ missing:(current:AdministrationView):AdministrationView=>({...current,roster:{...current.roster!,members:current.roster!.members.map(row=>({...row,member:{...row.member,exceptions:null}}))},controls:{...current.controls,memberIds:[]}})},
+ {kind:'policy', open:'Edit access rules', field:'Profile to customize', value:'restricted',
+ missing:(current:AdministrationView):AdministrationView=>({...current,policy:null,controls:{...current.controls,policy:false}})},
+];
+for(const scenario of draftFactCases) test('HADB-03: '+scenario.kind+' drafts hide when current readable facts disappear and return only to the same owner',async()=>{
+ const current=view(),f=await mount(current);await f.click(scenario.open);
+ if(scenario.kind==='other alias')await f.change('Use Home Assistant name',false);
+ await f.change(scenario.field,scenario.value);
+ f.element.view=scenario.missing(current);await f.element.updateComplete;
+ expect(f.root.querySelector('form, input, select'),scenario.kind+' previous fields must be hidden').toBeNull();
+ expect(f.text()).not.toContain('Original revision');expect(f.text()).toContain('This draft is hidden until its current data is available');
+ expect(f.intents).toEqual([]);
+ f.element.view=current;await f.element.updateComplete;expect(f.input(scenario.field).value).toBe(scenario.value);
+ expect(f.intents).toEqual([]);
+});
+test('HADB-03: a confirmed private preference draft and its detached save callback stay hidden and inert after read loss',async()=>{
+ const current=view(),f=await mount(current);await f.click('Edit preferences');await f.change('Saved language','en');await f.click('Review changes');
+ const save=f.button('Save changes');
+ f.element.view={...current,preferences:null,controls:{...current.controls,preferences:false}};await f.element.updateComplete;
+ expect(f.text()).not.toContain('Original revision');expect(f.text()).not.toContain('Saved language:');expect(f.button('Save changes')).toBeUndefined();
+ save.dispatchEvent(new MouseEvent('click'));await f.element.updateComplete;expect(f.intents).toEqual([]);
+ f.element.view=view({preferences:{...current.preferences!,locale:'es',revision:revisionValue('14')}});await f.element.updateComplete;
+ await f.click('Save changes');expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{expected_revision:'13',locale:'en'}}});
+});
+
+test('HADW-01: own alias falls back to permitted roster facts independently of directory availability',async()=>{
+ const current=view(),f=await mount({...current,presentation:null,directory:null});
+ await f.click('Edit own alias');expect(f.input('Domus alias').value).toBe('My alias');
+ await f.change('Use Home Assistant name',true);await f.click('Review changes');await f.click('Save changes');
+ expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{expected_revision:'11',alias:null}}});
+});
+test('HADW-02: browser-language preferences explicitly save null without implying notification delivery',async()=>{
+ const current=view(),f=await mount({...current,preferences:{...current.preferences!,locale:null,routine_participation_summaries:false}});
+ await f.click('Edit preferences');expect(f.input('Saved language').value).toBe('');await f.click('Review changes');await f.click('Save changes');
+ expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{locale:null,routine_participation_summaries:false}}});
+ expect(f.text()).toContain('Follow the browser');expect(f.text()).toContain('sends no notification');
+});
+test('HADB-02: only explicit reset adopts newer facts; cancel and close restore focus without submitting',async()=>{
+ const current=view(),f=await mount(current);await f.click('Edit household');await f.change('New household name','Typed draft');
+ f.element.view=view({household:{...current.household!,name:'Changed remotely',revision:revisionValue('8')}});await f.element.updateComplete;
+ expect(f.input('New household name').value).toBe('Typed draft');await f.click('Reset from current data');
+ expect(f.input('New household name').value).toBe('Changed remotely');expect(f.root.activeElement).toBe(f.input('New household name'));
+ await f.click('Review changes');await f.click('Back to editing');expect(f.input('New household name').value).toBe('Changed remotely');
+ await f.click('Review changes');await f.click('Save changes');expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{expected_revision:'8'}}});
+ await f.click('Close editor');expect(f.root.activeElement).toBe(f.button('Edit household'));expect(f.root.querySelector('form')).toBeNull();
+ await f.click('Edit household');await f.click('Cancel');expect(f.root.querySelector('form')).toBeNull();expect(f.intents).toHaveLength(1);
+});
+test('HADW-04: member exceptions change only selected ordinary grants and preserve unrelated decisions',async()=>{
+ const current=view(),f=await mount({...current,roster:{...current.roster!,members:[{...current.roster!.members[0],member:{...member,
+ exceptions:[{permission:'shopping.request',allow:true},{permission:'tasks.read',allow:false}]}}]}});
+ await f.click('Manage participant: My alias');expect(f.input('Household profile').value).toBe('viewer');
+ await f.change('View tasks','inherit');await f.change('View household','allow');await f.change('Request shopping','deny');
+ await f.change('Household profile','adult');await f.change('Membership status','suspended');await f.click('Review changes');
+ expect(f.text()).toContain('View household: Allow');await f.click('Save changes');
+ expect(f.intents[0]).toMatchObject({type:'submit',command:{action:'member.update',memberId:'owner',payload:{role:'adult',status:'suspended',
+ exceptions:[{permission:'household.read',allow:true},{permission:'shopping.request',allow:false}]}}});
+});
+test('HADW-05: per-profile edits retain another role for the same permission and remove only the explicitly inherited rule',async()=>{
+ const current=view(),f=await mount({...current,policy:{...current.policy!,adjustments:[
+ {role:'restricted',permission:'household.read',allow:false},{role:'adult',permission:'household.read',allow:true}]}});
+ await f.click('Edit access rules');await f.change('View household','deny');await f.change('View household','inherit');
+ await f.change('Profile to customize','restricted');expect(f.input('View household').value).toBe('deny');
+ await f.click('Review changes');await f.click('Save changes');
+ expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{adjustments:[{role:'restricted',permission:'household.read',allow:false}]}}});
+});
+test('HADW-05: invalid injected profile and permission options show a bounded error and cannot become grants',async()=>{
+ const f=await mount();await f.click('Edit access rules');
+ for(const name of ['Profile to customize','View household']) {
+ const select=f.input(name) as HTMLSelectElement,option=document.createElement('option');option.value='foreign';select.append(option);
+ select.value='foreign';select.dispatchEvent(new Event('change'));await f.element.updateComplete;
+ expect(f.root.querySelector('[role=alert]')?.textContent).toContain('Review these values');
+ }
+ expect(f.intents).toEqual([]);await f.click('Review changes');await f.click('Save changes');
+ expect(f.intents[0]).toMatchObject({type:'submit',command:{payload:{adjustments:[{role:'restricted',permission:'shopping.request',allow:false}]}}});
+});
+test.each(['absent','denied','unavailable'] as const)('HADB-03: %s HA names remain distinct facts with no invented identity',async state=>{
+ const current=view(),f=await mount({...current,presentation:{...presentation,alias:null},directory:{state:'unavailable',profiles:[]},
+ roster:{directoryState:'unavailable',members:[{...current.roster!.members[0],presentation:{...presentation,alias:null},metadata:{state,name:null,avatarState:'unavailable'}}]}});
+ expect(f.text()).toContain(state==='absent'?'HA name confirmed absent':state==='denied'?'HA name access denied':'HA name unavailable');
+ expect(f.text()).not.toContain('Verified owner');expect(f.intents).toEqual([]);
+});
+test.each(['committed','historical'] as const)('HADB-02: %s original outcomes do not populate forms or substitute historical result values for current facts',async stage=>{
+ const f=await mount(view({outcome:{kind:'receipt',stage,action:'member.presentation.update',operationId:'op_original',archivedBy:stage==='historical'?'restore_original':null,
+ createdAt:1000,result:{memberId:'owner',alias:'Hidden historical alias',revision:revisionValue('1')},notificationDelivery:'unavailable',eventDelivery:null}}));
+ expect(f.text()).toContain(stage==='historical'?'Its effect is absent from current state':'Review current facts');
+ expect(f.text()).toContain('op_original');expect(f.text()).not.toContain('Hidden historical alias');
+ await f.click('Edit own alias');expect(f.input('Domus alias').value).toBe('My alias');expect(f.intents).toEqual([]);
+});
+test.each([null,'correlation_original'])('HADB-02: rejected outcomes show safe reason/reference without claiming a submitted draft changed current facts (%s)',async correlationId=>{
+ const f=await mount(view({notice:'conflict',outcome:{kind:'rejected',reason:'conflict',correlationId}}));
+ expect(f.text()).toContain('Change not accepted');expect(f.text()).toContain('Your draft keeps its original revision');
+ if(correlationId===null)expect(f.text()).not.toContain('Request reference');else expect(f.text()).toContain(correlationId);
+ expect(f.intents).toEqual([]);
+});
+test('HADB-02: uncertain original outcome asks for explicit inspection and never sends automatically',async()=>{
+ const f=await mount(view({outcome:{kind:'uncertain'}}));expect(f.text()).toContain('Outcome not confirmed');expect(f.intents).toEqual([]);
+});
+function originalRecords() {
+ return (['prepared','uncertain','committed','historical','rejected'] as const).map((stage,index)=>({
+ action:'member.preferences.update' as const,payload:{expected_revision:revisionValue('13'),locale:'es' as const,routine_participation_summaries:true},
+ context,key:'original-'+index,createdAt:1000,expiresAt:3601000,stage,operationId:stage==='committed'||stage==='historical'?'op_'+index:null}));
+}
+test('HADB-02: bounded original rows expose no payload; only explicit currently allowed recovery and resolved dismissal emit intents',async()=>{
+ const current=view(),records=originalRecords(),f=await mount({...current,session:{...current.session!,access:'read_only'},
+ journal:{context,observedAt:1000,records},controls:{...current.controls,recoverableKeys:['original-0'],dismissibleKeys:['original-2']}});
+ expect(f.root.querySelectorAll('.requests>li')).toHaveLength(5);expect(f.text()).not.toContain('expected_revision');expect(f.text()).not.toContain('routine_participation_summaries');
+ const rows=[...f.root.querySelectorAll('.requests>li')],recover=rows[0].querySelector('button')!,dismiss=rows[2].querySelectorAll('button')[1];
+ recover.click();dismiss.click();expect(f.intents).toEqual([{type:'recover',key:'original-0'},{type:'dismiss',key:'original-2'}]);
+ f.element.view={...current,journal:{context,observedAt:1000,records},controls:{...current.controls,recoverableKeys:[],dismissibleKeys:[]}};await f.element.updateComplete;
+ recover.dispatchEvent(new MouseEvent('click'));dismiss.dispatchEvent(new MouseEvent('click'));expect(f.intents).toHaveLength(2);
+ f.element.pending=true;await f.element.updateComplete;recover.dispatchEvent(new MouseEvent('click'));expect(f.intents).toHaveLength(2);
+});
+test('HADB-01: inactive or unverified states discard drafts and offer only explicit refresh',async()=>{
+ const current=view(),f=await mount(current);await f.click('Edit preferences');await f.change('Saved language','en');
+ f.element.view={...current,phase:'ready',session:null,access:null,notice:'authentication_required'};await f.element.updateComplete;
+ expect(f.root.querySelector('form')).toBeNull();expect(f.text()).toContain('Previous private data is hidden');await f.click('Refresh administration');
+ expect(f.intents).toEqual([{type:'refresh'}]);f.element.view=current;await f.element.updateComplete;await f.click('Edit preferences');
+ expect(f.input('Saved language').value).toBe('es');await f.click('Cancel');await f.click('Refresh administration');expect(f.intents).toHaveLength(2);
+});
+test('HADB-01: disconnected widgets cannot retain private inputs when reattached',async()=>{
+ const current=view(),f=await mount(current);await f.click('Edit household');await f.change('New household name','Private disconnected draft');
+ f.element.remove();expect(f.element.view).toBeNull();document.body.append(f.element);f.element.view=current;await f.element.updateComplete;
+ await f.click('Edit household');expect(f.input('New household name').value).toBe('Household');expect(f.intents).toEqual([]);
+});
+for(const scenario of draftFactCases)test('HADB-01: a retained '+scenario.kind+' open callback cannot reopen unavailable facts',async()=>{
+ const current=view(),f=await mount(current),open=f.button(scenario.open);
+ f.element.view=scenario.missing(current);await f.element.updateComplete;open.dispatchEvent(new MouseEvent('click'));await f.element.updateComplete;
+ expect(f.root.querySelector('form')).toBeNull();expect(f.intents).toEqual([]);
+});
+test('HADB-01: previously attached form events cannot edit, submit, reset or close drafts while current proof is hidden',async()=>{
+ const current=view(),f=await mount(current);await f.click('Edit access rules');const role=f.input('Profile to customize'),rule=f.input('View household');
+ const reset=f.button('Reset from current data'),cancel=f.button('Cancel'),form=f.root.querySelector('form')!;
+ f.element.pending=true;await f.element.updateComplete;role.value='restricted';role.dispatchEvent(new Event('change'));rule.value='allow';rule.dispatchEvent(new Event('change'));
+ form.dispatchEvent(new Event('submit',{cancelable:true}));reset.dispatchEvent(new MouseEvent('click'));cancel.dispatchEvent(new MouseEvent('click'));await f.element.updateComplete;
+ f.element.pending=false;await f.element.updateComplete;expect(f.input('Profile to customize').value).toBe('adult');expect(f.input('View household').value).toBe('inherit');expect(f.intents).toEqual([]);
+});
+test('HADB-01: an old review, alias input, checkbox and close callback cannot affect a replacement draft',async()=>{
+ const current=view(),f=await mount(current);await f.click('Edit own alias');const field=f.input('Domus alias'),clear=f.input('Use Home Assistant name'),form=f.root.querySelector('form')!;
+ await f.click('Review changes');const save=f.button('Save changes'),back=f.button('Back to editing'),close=f.button('Close editor');
+ await f.click('Reset from current data');field.value='Stale callback';field.dispatchEvent(new Event('input'));(clear as HTMLInputElement).checked=true;clear.dispatchEvent(new Event('change'));
+ form.dispatchEvent(new Event('submit',{cancelable:true}));save.dispatchEvent(new MouseEvent('click'));back.dispatchEvent(new MouseEvent('click'));close.dispatchEvent(new MouseEvent('click'));await f.element.updateComplete;
+ expect(f.input('Domus alias').value).toBe('My alias');expect((f.input('Use Home Assistant name') as HTMLInputElement).checked).toBe(false);expect(f.intents).toEqual([]);
+});
+
+test('HADB-03: unknown or inherited translation keys use a localized bounded unavailable message',()=>{
+ expect(administrationMessage('foreign','en')).toBe(administrationMessage('notice_unavailable','en'));
+ expect(administrationMessage('constructor','es')).toBe(administrationMessage('notice_unavailable','es'));
+});
+test('HADB-03: read-only current facts keep a draft inspectable but lock every edit until write access returns',async()=>{
+ const current=view(),f=await mount(current);await f.click('Edit household');await f.change('New household name','Unsent original');
+ const open=f.button('Edit preferences');f.element.view={...current,session:{...current.session!,access:'read_only'},notice:'read_only',
+ controls:{...current.controls,household:false,policy:false,preferences:false,ownAlias:false,memberIds:[],aliasMemberIds:[]}};await f.element.updateComplete;
+ expect(f.input('New household name').value).toBe('Unsent original');expect(f.input('New household name').disabled).toBe(true);expect(f.text()).toContain('This form needs current write access');
+ open.dispatchEvent(new MouseEvent('click'));await f.element.updateComplete;expect(f.root.querySelectorAll('form')).toHaveLength(1);expect(f.intents).toEqual([]);
+ f.element.view=current;await f.element.updateComplete;expect(f.input('New household name').value).toBe('Unsent original');
+});
+test('HADB-01: hidden drafts can be explicitly closed without revealing their fields or sending work',async()=>{
+ const current=view(),f=await mount(current);await f.click('Edit preferences');f.element.view={...current,preferences:null,controls:{...current.controls,preferences:false}};await f.element.updateComplete;
+ await f.click('Close editor');f.element.view=current;await f.element.updateComplete;expect(f.root.querySelector('form')).toBeNull();expect(f.intents).toEqual([]);
+});
+test('HADB-01: a cached open control is inert during checking, and an idle state offers truthful refresh guidance',async()=>{
+ const current=view(),f=await mount(current),open=f.button('Edit household');f.element.pending=true;await f.element.updateComplete;
+ open.dispatchEvent(new MouseEvent('click'));await f.element.updateComplete;expect(f.root.querySelector('form')).toBeNull();
+ f.element.pending=false;f.element.view={...current,phase:'idle',notice:null};await f.element.updateComplete;
+ expect(f.text()).toContain('The requested information is unavailable');await f.click('Refresh administration');expect(f.intents).toEqual([{type:'refresh'}]);
+});
+test('HADW-05: unnamed supported directory accounts remain explicit ID targets without a fabricated display name',async()=>{
+ const current=view(),f=await mount({...current,directory:{state:'available',profiles:[{haUserId:context.subject,name:null}]}});
+ await f.click('Edit access rules');await f.change('HA administrator mapping','setup_administrator');
+ expect((f.input('Target Home Assistant account') as HTMLSelectElement).options[1].textContent).toContain('Name unavailable');expect(f.intents).toEqual([]);
+});
+test('HADB-02: a preflight conflict does not claim that an original server request exists',async()=>{
+ const current=view(),f=await mount(current);await f.click('Edit household');await f.change('New household name','Original draft');
+ await f.click('Review changes');await f.click('Save changes');f.element.view={...current,notice:'conflict',outcome:null};await f.element.updateComplete;
+ expect(f.text()).toContain('Your draft keeps its original revision');expect(f.text()).not.toContain('This request was submitted');
+ expect(f.text()).not.toContain('Check its original result below');expect(f.intents).toHaveLength(1);
+});
+test('HADB-03: participant review names the exact target and its stable membership reference',async()=>{
+ const f=await mount();await f.click('Manage participant: Pending member');await f.click('Review changes');
+ const editor=f.root.querySelector('[role=region][aria-label="Manage participant"]')!;
+ expect(editor.textContent).toContain('Participant: Pending member');expect(editor.querySelector('code')?.textContent).toBe('other');
+ expect(f.intents).toEqual([]);
+});
+
+test.each([null,'Independently read alias'])('HADW-01: own alias target remains explicit without roster access (%s)',async alias=>{
+ const current=view(),f=await mount({...current,roster:null,presentation:{...presentation,alias}});await f.click('Edit own alias');
+ const editor=f.root.querySelector('[role=region][aria-label="Domus alias"]')!;expect(editor.textContent).toContain('Participant: '+(alias??'Verified owner'));
+ expect(editor.querySelector('code')?.textContent).toBe('owner');expect(f.intents).toEqual([]);
+});
diff --git a/frontend/tests/administration-controller.test.ts b/frontend/tests/administration-controller.test.ts
index 415f4a4..9c62c08 100644
--- a/frontend/tests/administration-controller.test.ts
+++ b/frontend/tests/administration-controller.test.ts
@@ -7,7 +7,7 @@ import {AdministrationSessionJournal, ADMINISTRATION_JOURNAL_KEY} from '../src/a
import {administrationCommand} from '../src/domain/administration-commands';
import type {AdministrationCommand} from '../src/domain/administration-commands';
import {revisionValue} from '../src/domain/administration';
-import type {AdministrationAccess, AdministrationReceipt, OwnPreferences} from '../src/domain/administration-views';
+import type {AdministrationAccess, AdministrationReceipt, OwnPreferences, Roster} from '../src/domain/administration-views';
function read(value: T): EntryRead {return {result: Promise.resolve(value), cancel: vi.fn()};}
function deferred() {
@@ -57,7 +57,7 @@ function fixture(initial = access, mode: 'native' | 'standalone' = 'standalone')
const server = {
capabilities: vi.fn(() => read({contractVersion: 2 as const, householdId: 'home', actions: commands.map(c => c.action)})),
authorization: vi.fn(() => read(initial)), household: vi.fn(() => read(home)), policy: vi.fn(() => read(policy)),
- roster: vi.fn(() => read({directoryState: 'available' as const, members: [{member, presentation: own,
+ roster: vi.fn(() => read({directoryState: 'available' as const, members: [{member, presentation: own,
metadata: {state: 'available' as const, name: 'Verified owner', avatarState: 'unavailable' as const}}]})),
directory: vi.fn(() => read({state: 'denied' as const, profiles: []})), preferences: vi.fn(() => read(preferences)),
presentation: vi.fn(() => read(own)),
@@ -608,3 +608,103 @@ test('HADW-01: a permitted current roster remains an honest alias source when th
await f.controller.refresh(); expect(f.controller.view.presentation).toBeNull(); expect(f.controller.view.errors.presentation).toBe('unavailable');
await f.controller.submit(commands[3]); expect(f.server.send).toHaveBeenCalledOnce(); expect(f.controller.view.outcome?.kind).toBe('receipt');
});
+
+const noControls = {household: false, policy: false, preferences: false, ownAlias: false,
+ memberIds: [], aliasMemberIds: [], recoverableKeys: [], dismissibleKeys: []};
+test('HADB-03: idle and disposed views offer no administration controls and obtain no proof', async () => {
+ const f = fixture(); expect(f.controller.view.controls).toEqual(noControls);
+ f.controller.close(); expect(f.controller.view.controls).toEqual(noControls);
+ expect(f.identity.session).not.toHaveBeenCalled(); expect(f.storage.getItem).not.toHaveBeenCalled();
+});
+test('HADB-03: controls describe current permitted facts without producing a command key, lookup or private payload', async () => {
+ const f = fixture(); await f.controller.refresh(); const writes = f.storage.setItem.mock.calls.length;
+ const controls = f.controller.view.controls;
+ expect(controls).toEqual({...noControls, household: true, policy: true, preferences: true, ownAlias: true, memberIds: [member.memberId]});
+ expect(Object.isFrozen(controls)).toBe(true); expect(Object.isFrozen(controls.memberIds)).toBe(true);
+ expect(Object.isFrozen(controls.aliasMemberIds)).toBe(true); expect(Object.isFrozen(controls.recoverableKeys)).toBe(true);
+ expect(Object.isFrozen(controls.dismissibleKeys)).toBe(true);
+ expect(JSON.stringify(controls)).not.toContain('locale'); expect(JSON.stringify(controls)).not.toContain('routine_participation');
+ expect(f.storage.setItem).toHaveBeenCalledTimes(writes); expect(f.entropy.requestKey).not.toHaveBeenCalled();
+ expect(f.server.original).not.toHaveBeenCalled(); expect(f.server.send).not.toHaveBeenCalled();
+});
+test('HADB-03: other-member controls require current facts and never invent masked exceptions', async () => {
+ const f = fixture(); f.server.roster.mockReturnValue(read({directoryState: 'available', members: [
+ {member, presentation: own, metadata: {state: 'available', name: 'Owner', avatarState: 'unavailable'}},
+ {member: {...member, memberId: 'other', exceptions: null}, presentation: {...own, memberId: 'other'},
+ metadata: {state: 'denied', name: null, avatarState: 'unavailable'}},
+ ]}));
+ await f.controller.refresh(); expect(f.controller.view.controls.memberIds).toEqual([member.memberId]);
+ expect(f.controller.view.controls.aliasMemberIds).toEqual(['other']);
+});
+test('HADB-03: ordinary current owner controls expose own edits without exposing other-member management', async () => {
+ const f = fixture({...access, permissions: ['members.read','preferences.read','preferences.write']});
+ await f.controller.refresh(); expect(f.controller.view.controls).toEqual({...noControls, ownAlias: true, preferences: true});
+});
+test('HADB-03: write grants without current aggregate reads do not invent an edit revision, while original GET remains usable', async () => {
+ const f = fixture({...access, permissions: ['household.manage','operations.read']});
+ f.journal.open(context,1000); f.journal.prepare('original',commands[0],1000); f.journal.detach();
+ await f.controller.refresh(); expect(f.controller.view.controls).toEqual({...noControls, recoverableKeys: ['original']});
+ expect(f.server.original).not.toHaveBeenCalled(); await f.controller.recover('original');
+ expect(f.server.original).toHaveBeenCalledOnce(); expect(f.controller.view.controls.dismissibleKeys).toEqual(['original']);
+});
+test('HADB-03: a valid read-only lease offers original recovery and resolved dismissal, with all new edits unavailable', async () => {
+ const f = fixture(); f.journal.open(context,1000); f.journal.prepare('saved',commands[4],1000);
+ f.journal.record('saved',{stage:'committed',operationId:'operation'},1000); f.journal.detach();
+ f.identity.session.mockReturnValue(read({...session,access:'read_only'})); await f.controller.refresh();
+ expect(f.controller.view.controls).toEqual({...noControls,recoverableKeys:['saved'],dismissibleKeys:['saved']});
+ expect(f.server.original).not.toHaveBeenCalled(); expect(f.server.send).not.toHaveBeenCalled();
+});
+test('HADB-03: original stages expose only their permitted recovery and dismissal controls', async () => {
+ const f = fixture(); f.journal.open(context,1000);
+ for (const stage of ['prepared','uncertain','committed','historical','rejected'] as const) {
+ f.journal.prepare(stage,commands[4],1000);
+ if (stage !== 'prepared') f.journal.record(stage,{stage,operationId:stage==='committed'||stage==='historical'?'operation-'+stage:null},1000);
+ }
+ f.journal.detach(); await f.controller.refresh();
+ expect(f.controller.view.controls.recoverableKeys).toEqual(['prepared','uncertain','committed','historical']);
+ expect(f.controller.view.controls.dismissibleKeys).toEqual(['committed','historical','rejected']);
+ f.server.authorization.mockReturnValue(read({...access,permissions:access.permissions.filter(value=>value!=='operations.read')}));
+ await f.controller.refresh(); expect(f.controller.view.controls.recoverableKeys).toEqual([]);
+ expect(f.controller.view.controls.dismissibleKeys).toEqual(['committed','historical','rejected']);
+});
+test('HADB-03: unavailable owned journal disables controls while current permitted facts remain readable', async () => {
+ const f = fixture(); f.storage.getItem.mockImplementation(()=>{throw new Error('private-storage-fault');});
+ await f.controller.refresh(); expect(f.controller.view.preferences).toEqual(preferences);
+ expect(f.controller.view.controls).toEqual(noControls);
+});
+test('HADB-03: pending proof and an in-flight atomic change offer no stale form controls', async () => {
+ const f = fixture(), proof = deferred(); f.identity.session.mockReturnValueOnce(proof);
+ const opening = f.controller.refresh(); expect(f.controller.view.controls).toEqual(noControls);
+ proof.resolve(session); await opening; const pending = deferred(); f.server.send.mockReturnValueOnce(pending.result);
+ const work = f.controller.submit(commands[4]); await settle(); expect(f.controller.view.controls).toEqual(noControls);
+ pending.resolve(receipt(commands[4])); await work; expect(f.controller.view.controls.preferences).toBe(true);
+});
+test('HADB-03: an unresolved original disables only its aggregate and leaves independent forms available', async () => {
+ const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind:'uncertain'});
+ await f.controller.submit(commands[4]); const controls = f.controller.view.controls;
+ expect(controls.preferences).toBe(false); expect(controls.household).toBe(true); expect(controls.ownAlias).toBe(true);
+ expect(controls.recoverableKeys).toEqual(['request-original']); expect(f.server.send).toHaveBeenCalledOnce();
+});
+test('HADB-03: an unresolved own alias does not disable an authorized different-member alias form', async () => {
+ const f = fixture(); f.server.roster.mockReturnValue(read({directoryState:'available',members:[
+ {member,presentation:own,metadata:{state:'available',name:'Owner',avatarState:'unavailable'}},
+ {member:{...member,memberId:'other'},presentation:{...own,memberId:'other'},metadata:{state:'available',name:'Other',avatarState:'unavailable'}},
+ ]}));
+ await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind:'uncertain'}); await f.controller.submit(commands[3]);
+ expect(f.controller.view.controls.ownAlias).toBe(false); expect(f.controller.view.controls.aliasMemberIds).toEqual(['other']);
+ expect(f.controller.view.controls.preferences).toBe(true);
+});
+test.each([0,2,4])('household-browser-C13: a captured new intent %s cannot cross a same-HA membership replacement with a coincident revision',async index=>{
+ const f=fixture();await f.controller.refresh();const next={...member,memberId:'replacement'};
+ f.server.authorization.mockReturnValue(read({...access,member:next}));
+ f.server.preferences.mockReturnValue(read({...preferences,memberId:next.memberId}));
+ f.server.presentation.mockReturnValue(read({...own,memberId:next.memberId}));
+ f.server.roster.mockReturnValue(read({directoryState:'available',members:[{member:next,presentation:{...own,memberId:next.memberId},
+ metadata:{state:'available',name:'Current member',avatarState:'unavailable'}}]}));
+ await f.controller.submit(commands[index]);expect(f.server.send).not.toHaveBeenCalled();expect(f.entropy.requestKey).not.toHaveBeenCalled();
+ expect(f.controller.view.notice).toBe('context_changed');expect(f.controller.view.preferences?.memberId).toBe(next.memberId);
+});
+test('HADB-03: a newer access revision for the same member still permits a currently authorized captured intent',async()=>{
+ const f=fixture();await f.controller.refresh();f.server.authorization.mockReturnValue(read({...access,policyRevision:revisionValue('6'),member:{...member,revision:revisionValue('4')}}));
+ await f.controller.submit(commands[4]);expect(f.server.send).toHaveBeenCalledOnce();expect(f.controller.view.outcome?.kind).toBe('receipt');
+});
diff --git a/frontend/tests/e2e/administration.spec.ts b/frontend/tests/e2e/administration.spec.ts
new file mode 100644
index 0000000..49edebe
--- /dev/null
+++ b/frontend/tests/e2e/administration.spec.ts
@@ -0,0 +1,130 @@
+import {expect,test} from '@playwright/test';
+import type {APIRequestContext,BrowserContext,Locator,Page} from '@playwright/test';
+
+const prefix='/api/hassio_ingress/fixture_domus_supervisor_1234567890';
+type Mode='native'|'standalone';
+const origin=(mode:Mode)=>`http://127.0.0.1:${mode==='native'?56174:56175}`;
+const base=(mode:Mode)=>origin(mode)+(mode==='native'?prefix:'');
+const journalKey='domus.administration.journal.v1';
+const statistics=async(request:APIRequestContext,mode:Mode)=>(await request.get(origin(mode)+'/__fixture__/stats')).json();
+async function signIn(page:Page,mode:Mode) {
+ if(mode==='standalone') {
+ await page.getByRole('button',{name:'Sign in with Home Assistant',exact:true}).click();
+ await page.getByRole('link',{name:'Continue as fixture user',exact:true}).click();
+ }
+}
+async function create(page:Page,context:BrowserContext,mode:Mode) {
+ if(mode==='native')await context.addCookies([{name:'ingress_session',value:'f'.repeat(128),domain:'127.0.0.1',path:'/',httpOnly:true,sameSite:'Strict'}]);
+ await page.goto(base(mode)+'/');await signIn(page,mode);
+ await page.getByLabel('Household name',{exact:true}).fill('Maple House');await page.getByLabel('Timezone',{exact:true}).fill('UTC');
+ await page.getByRole('button',{name:'Create household',exact:true}).click();
+ const widget=page.locator('domus-administration');await expect(widget.getByRole('heading',{name:'Household administration',exact:true})).toBeVisible();
+ await expect(widget.getByRole('button',{name:'Edit preferences',exact:true})).toBeEnabled();return widget;
+}
+async function save(widget:Locator) {
+ await widget.getByRole('button',{name:'Review changes',exact:true}).click();await expect(widget.getByRole('heading',{name:'Confirm this household change',exact:true})).toBeFocused();
+ await widget.getByRole('button',{name:'Save changes',exact:true}).click();
+ await expect(widget.getByRole('heading',{name:'Original request processed',exact:true})).toBeVisible();
+ await expect(widget.getByRole('button',{name:'Refresh administration',exact:true})).toBeEnabled();
+}
+for(const mode of ['native','standalone'] as const)test.describe('administration '+mode,()=>{
+ test.beforeEach(async({request})=>{expect((await request.post(origin(mode)+'/__fixture__/reset')).ok()).toBe(true);});
+ test('HADW-01: actual independent alias revision, inert text, reload and explicit clear persist through SQLite',async({page,context})=>{
+ const widget=await create(page,context,mode);const access=await(await page.request.get(base(mode)+'/api/v1/authorization')).json();
+ const path=base(mode)+'/api/v1/administration/members/'+access.member.member_id+'/presentation';
+ await widget.getByRole('button',{name:'Edit own alias',exact:true}).click();await widget.getByRole('checkbox',{name:'Use Home Assistant name',exact:true}).uncheck();
+ const alias='
& Maple';await widget.getByRole('textbox',{name:'Domus alias',exact:true}).fill(alias);await save(widget);
+ expect(await(await page.request.get(path)).json()).toEqual({member_id:access.member.member_id,alias,revision:2});await expect(widget.locator('img,script')).toHaveCount(0);
+ await page.reload();await expect(widget.getByText(alias,{exact:true}).first()).toBeVisible();
+ await widget.getByRole('button',{name:'Edit own alias',exact:true}).click();await widget.getByRole('checkbox',{name:'Use Home Assistant name',exact:true}).check();await save(widget);
+ expect(await(await page.request.get(path)).json()).toEqual({member_id:access.member.member_id,alias:null,revision:3});
+ });
+ test('HADW-02: actual private preference form saves Spanish and the summary choice without delivering a notification',async({page,context,request})=>{
+ const widget=await create(page,context,mode);await widget.getByRole('button',{name:'Edit preferences',exact:true}).click();
+ await widget.getByLabel('Saved language',{exact:true}).selectOption('es');await widget.getByRole('checkbox',{name:'Routine participation summaries',exact:true}).uncheck();
+ await widget.getByRole('button',{name:'Review changes',exact:true}).click();await widget.getByRole('button',{name:'Save changes',exact:true}).click();
+ await expect(widget.getByRole('heading',{name:'Petición original procesada',exact:true})).toBeVisible();await expect(page.locator('html')).toHaveAttribute('lang','es');
+ expect(await(await page.request.get(base(mode)+'/api/v1/administration/preferences')).json()).toMatchObject({locale:'es',routine_participation_summaries:false,revision:2,notification_delivery:'unavailable'});
+ expect((await statistics(request,mode)).counts.receipts).toBe(2);await page.reload();await expect(widget).toHaveAttribute('lang','es');
+ await widget.getByRole('button',{name:'Editar preferencias',exact:true}).click();await expect(widget.getByLabel('Idioma guardado',{exact:true})).toHaveValue('es');
+ await expect(widget.getByRole('checkbox',{name:'Resúmenes habituales de participación',exact:true})).not.toBeChecked();
+ });
+ test('HADW-04: actual household form saves the exact name/timezone once and reload reads the persisted revision',async({page,context,request})=>{
+ const widget=await create(page,context,mode);await widget.getByRole('button',{name:'Edit household',exact:true}).click();
+ await widget.getByRole('textbox',{name:'New household name',exact:true}).fill('Cedar House');await widget.getByRole('textbox',{name:'New timezone',exact:true}).fill('Europe/Madrid');await save(widget);
+ expect(await(await page.request.get(base(mode)+'/api/v1/household')).json()).toMatchObject({name:'Cedar House',time_zone:'Europe/Madrid',revision:2});
+ expect((await statistics(request,mode)).counts.receipts).toBe(2);await page.reload();await expect(page.getByText('Cedar House',{exact:true})).toBeVisible();
+ });
+ test('HADW-05: actual mapping/profile form preserves all other profiles and SQLite reauthorizes the selected account',async({page,context})=>{
+ const widget=await create(page,context,mode);await widget.getByRole('button',{name:'Edit access rules',exact:true}).click();
+ await widget.getByLabel('HA administrator mapping',{exact:true}).selectOption('selected_ha_user');await widget.getByLabel('Target Home Assistant account',{exact:true}).selectOption('a'.repeat(32));
+ await widget.getByLabel('Profile to customize',{exact:true}).selectOption('adult');await widget.getByLabel('View tasks',{exact:true}).selectOption('deny');await save(widget);
+ expect(await(await page.request.get(base(mode)+'/api/v1/policy')).json()).toMatchObject({revision:2,mapping:{mode:'selected_ha_user',target_ha_user_id:'a'.repeat(32)},
+ adjustments:[{role:'adult',permission:'tasks.read',allow:false}]});
+ await page.reload();await widget.getByRole('button',{name:'Edit access rules',exact:true}).click();
+ await expect(widget.getByLabel('HA administrator mapping',{exact:true})).toHaveValue('selected_ha_user');await expect(widget.getByLabel('View tasks',{exact:true})).toHaveValue('deny');
+ });
+ test('HADW-04: an actual second HA account requests membership before explicit restricted activation',async({page,context,browser,request})=>{
+ const widget=await create(page,context,mode);
+ await widget.getByRole('button',{name:'Edit access rules',exact:true}).click();await widget.getByLabel('HA administrator mapping',{exact:true}).selectOption('selected_ha_user');
+ await widget.getByLabel('Target Home Assistant account',{exact:true}).selectOption('a'.repeat(32));await save(widget);await widget.getByRole('button',{name:'Close editor',exact:true}).click();
+ const peer=await browser.newContext(),other=await peer.newPage();const subject=mode==='native'?'c':'b';
+ try {
+ if(mode==='native')await peer.addCookies([{name:'ingress_session',value:'e'.repeat(128),domain:'127.0.0.1',path:'/',httpOnly:true,sameSite:'Strict'}]);
+ else expect((await request.post(origin(mode)+'/__fixture__/identity',{data:{subject:subject.repeat(32)}})).ok()).toBe(true);
+ await other.goto(base(mode)+'/');await signIn(other,mode);await other.getByRole('button',{name:'Request membership',exact:true}).click();
+ await expect(other.getByRole('heading',{name:'Membership pending',exact:true})).toBeVisible();
+ if(mode==='standalone')await request.post(origin(mode)+'/__fixture__/identity',{data:{subject:'a'.repeat(32)}});
+ await widget.getByRole('button',{name:'Refresh administration',exact:true}).click();
+ await widget.getByRole('button',{name:'Manage participant: Fictional account '+subject,exact:true}).click();
+ await expect(widget.getByLabel('Household profile',{exact:true})).toHaveValue('restricted');await widget.getByLabel('Membership status',{exact:true}).selectOption('active');
+ await widget.getByRole('button',{name:'Review changes',exact:true}).click();await expect(widget.getByRole('alert')).toContainText('Confirm household access');
+ await widget.getByRole('checkbox',{name:'Approve household access',exact:true}).check();
+ await expect(widget.getByRole('region',{name:'Manage participant',exact:true})).toContainText('Participant: Fictional account '+subject);await save(widget);
+ const roster=await(await page.request.get(base(mode)+'/api/v1/administration/roster')).json();
+ expect(roster.members.find((row:{member:{ha_user_id:string}})=>row.member.ha_user_id===subject.repeat(32)).member).toMatchObject({role:'restricted',status:'active',revision:2});
+ }finally{await peer.close();}
+ });
+ test('HADB-02: a lost actual preference PUT reply recovers by original GET without another SQLite mutation',async({page,context,request})=>{
+ const widget=await create(page,context,mode),writes:{key:string;body:string|null}[]=[],lookups:string[]=[];
+ page.on('request',item=>{if(item.url().includes('/administration/requests/')||item.url().includes('/operations/'))lookups.push(item.url());});
+ await page.route(base(mode)+'/api/v1/administration/preferences',async route=>{
+ if(route.request().method()!=='PUT')return route.continue();writes.push({key:route.request().headers()['idempotency-key'],body:route.request().postData()});
+ const reply=await route.fetch({maxRetries:0,maxRedirects:0});expect(reply.status()).toBe(200);await route.abort('connectionreset');
+ });
+ await widget.getByRole('button',{name:'Edit preferences',exact:true}).click();await widget.getByLabel('Saved language',{exact:true}).selectOption('en');
+ await widget.getByRole('button',{name:'Review changes',exact:true}).click();await widget.getByRole('button',{name:'Save changes',exact:true}).click();
+ await expect(widget.getByRole('heading',{name:'Outcome not confirmed',exact:true})).toBeVisible();const before=await statistics(request,mode);
+ expect(before.counts.receipts).toBe(2);expect(writes).toHaveLength(1);expect(lookups).toEqual([]);await page.reload();
+ await expect(widget.getByRole('button',{name:'Check original request',exact:true})).toBeEnabled();expect(writes).toHaveLength(1);expect(lookups).toEqual([]);
+ await widget.getByRole('button',{name:'Check original request',exact:true}).click();await expect(widget.getByRole('heading',{name:'Original request processed',exact:true})).toBeVisible();
+ expect(writes).toHaveLength(1);expect(lookups).toEqual([base(mode)+'/api/v1/administration/requests/member.preferences.update/'+writes[0].key]);
+ expect(await statistics(request,mode)).toEqual(before);const records=await page.evaluate(key=>JSON.parse(sessionStorage.getItem(key)!).records,journalKey);
+ const wirePayload=JSON.parse(writes[0].body!);
+ expect(records[0]).toMatchObject({key:writes[0].key,stage:'committed',payload:{...wirePayload,expected_revision:String(wirePayload.expected_revision)}});
+ });
+ test('HADB-02: stale actual household revision retains the original draft until an explicit reset',async({page,context})=>{
+ const widget=await create(page,context,mode);await widget.getByRole('button',{name:'Edit household',exact:true}).click();await widget.getByRole('textbox',{name:'New household name',exact:true}).fill('Unsent draft');
+ const csrf=await(await page.request.get(base(mode)+'/api/v1/auth/csrf')).json();expect((await page.request.put(base(mode)+'/api/v1/household',{
+ data:{expected_revision:1,name:'Other current name',time_zone:'UTC'},headers:{Origin:origin(mode),'X-CSRF-Token':csrf.csrf_token,'Idempotency-Key':'explicit-concurrent-household'}})).status()).toBe(200);
+ await widget.getByRole('button',{name:'Review changes',exact:true}).click();await widget.getByRole('button',{name:'Save changes',exact:true}).click();
+ await expect(widget.getByText('The data changed. Your draft keeps its original revision; reset explicitly to adopt current data.',{exact:true})).toBeVisible();
+ await expect(widget.getByText('This request was submitted.',{exact:false})).toHaveCount(0);await expect(widget.getByText('New household name',{exact:true})).toBeVisible();
+ await expect(widget.getByText('Unsent draft',{exact:true})).toBeVisible();await widget.getByRole('button',{name:'Reset from current data',exact:true}).click();
+ await expect(widget.getByRole('textbox',{name:'New household name',exact:true})).toHaveValue('Other current name');
+ await widget.getByRole('textbox',{name:'New household name',exact:true}).fill('Explicit new work');await save(widget);
+ expect(await(await page.request.get(base(mode)+'/api/v1/household')).json()).toMatchObject({name:'Explicit new work',revision:3});
+ });
+ test('HADB-02: actual console restoration keeps original history without reviving the removed public alias effect',async({page,context,request})=>{
+ const widget=await create(page,context,mode);expect((await request.post(origin(mode)+'/__fixture__/operator',{data:{action:'snapshot'}})).ok()).toBe(true);
+ await widget.getByRole('button',{name:'Edit own alias',exact:true}).click();await widget.getByRole('checkbox',{name:'Use Home Assistant name',exact:true}).uncheck();
+ await widget.getByRole('textbox',{name:'Domus alias',exact:true}).fill('Rolled away alias');await save(widget);
+ expect((await request.post(origin(mode)+'/__fixture__/operator',{data:{action:'restore'}})).ok()).toBe(true);const before=await statistics(request,mode);
+ await page.reload();await signIn(page,mode);await expect(widget.getByRole('heading',{name:'Household administration',exact:true})).toBeVisible();
+ await expect(widget.getByText('Rolled away alias',{exact:true})).toHaveCount(0);await widget.getByRole('button',{name:'Check original request',exact:true}).click();
+ await expect(widget.getByRole('heading',{name:'Processed before restoration',exact:true})).toBeVisible();
+ await expect(widget.getByText('This operation was processed before restoration. Its effect is absent from current state.',{exact:true})).toBeVisible();
+ await widget.getByRole('button',{name:'Edit own alias',exact:true}).click();await expect(widget.getByRole('checkbox',{name:'Use Home Assistant name',exact:true})).toBeChecked();
+ expect((await statistics(request,mode)).counts).toEqual(before.counts);
+ });
+});
diff --git a/frontend/tests/e2e/catalog.spec.ts b/frontend/tests/e2e/catalog.spec.ts
index 416e273..9f90fa5 100644
--- a/frontend/tests/e2e/catalog.spec.ts
+++ b/frontend/tests/e2e/catalog.spec.ts
@@ -102,3 +102,51 @@ test('the built interface fits 360 pixels with light/dark contrast and reduced-m
await page.screenshot({path: `test-results/setup-es-360-${colorScheme}.png`, fullPage: true});
}
});
+
+const administrationUrl=(example:string,locale:string,deployment:string)=>origin+'/iframe.html?id=domus-household-administration--'+example
+ +'&viewMode=story&args='+encodeURIComponent(`language:${locale};deployment:${deployment}`);
+for(const example of ['active','read-only','sources-unavailable','uncertain','historical'])test('HADB-03: built administration '+example+' is localized in both deployments without external requests',async({page})=>{
+ const errors:string[]=[],foreign:string[]=[];page.on('pageerror',error=>errors.push(error.message));
+ await page.route('**/*',async route=>{if(new URL(route.request().url()).origin!==origin){foreign.push(route.request().url());await route.abort();}else await route.continue();});
+ for(const [locale,deployment] of [['en','native'],['en','standalone'],['es','native'],['es','standalone']]) {
+ await page.goto(administrationUrl(example,locale,deployment));const widget=page.locator('domus-administration');
+ await expect(widget.getByRole('heading',{name:locale==='es'?'Administración del hogar':'Household administration',exact:true})).toBeVisible();
+ await expect(widget).toHaveAttribute('lang',locale);await expect(page.getByText('Fictional administration state.',{exact:false})).toBeVisible();
+ await expect(widget.getByRole('button',{name:locale==='es'?'Editar preferencias':'Edit preferences',exact:true})).toBeEnabled({enabled:example!=='read-only'&&example!=='uncertain'});
+ if(example==='historical')await expect(widget.getByText(locale==='es'?'Esta operación se procesó antes de la restauración. Su efecto no está presente en el estado actual.':'This operation was processed before restoration. Its effect is absent from current state.',{exact:true})).toBeVisible();
+ if(example==='sources-unavailable')await expect(widget.getByText(locale==='es'?'Nombre de Home Assistant: Acceso al nombre de HA denegado':'Home Assistant name: HA name access denied',{exact:true})).toBeVisible();
+ }
+ expect(foreign).toEqual([]);expect(errors).toEqual([]);
+});
+test('HADB-03: built administration supports keyboard review, focus, locale retention and one exact immutable intent',async({page})=>{
+ await page.goto(administrationUrl('active','en','native'));const widget=page.locator('domus-administration');
+ await widget.evaluate(element=>{const current=window as unknown as {administrationIntents:unknown[]};current.administrationIntents=[];
+ element.addEventListener('domus-administration-intent',event=>current.administrationIntents.push((event as CustomEvent).detail));});
+ await widget.getByRole('button',{name:'Edit own alias',exact:true}).click();await expect(widget.getByLabel('Use Home Assistant name',{exact:true})).toBeFocused();
+ await page.keyboard.press('Space');await page.keyboard.press('Tab');await expect(widget.getByRole('textbox',{name:'Domus alias',exact:true})).toBeFocused();
+ const alias='
& Maple';await page.keyboard.insertText(alias);await page.keyboard.press('Tab');await page.keyboard.press('Enter');
+ await expect(widget.getByRole('heading',{name:'Confirm this household change',exact:true})).toBeFocused();await expect(widget.locator('img,script')).toHaveCount(0);
+ await widget.evaluate(element=>{(element as HTMLElement & {language:string}).language='es';});
+ await expect(widget.getByRole('heading',{name:'Confirma este cambio del hogar',exact:true})).toBeVisible();
+ await page.keyboard.press('Tab');await expect(widget.getByRole('button',{name:'Guardar cambios',exact:true})).toBeFocused();await page.keyboard.press('Enter');
+ const intents=await page.evaluate(()=>(window as unknown as {administrationIntents:unknown[]}).administrationIntents);
+ expect(intents).toEqual([{type:'submit',command:{action:'member.presentation.update',memberId:'fictional-owner',payload:{expected_revision:'11',alias}}}]);
+ await expect(widget.getByRole('button',{name:'Guardar cambios',exact:true})).toBeDisabled();
+});
+test('HADB-03: built administration forms fit 360 pixels in both themes with reduced motion and readable action contrast',async({page})=>{
+ await page.setViewportSize({width:360,height:800});
+ for(const colorScheme of ['light','dark'] as const) {
+ await page.emulateMedia({colorScheme,reducedMotion:'reduce'});await page.goto(administrationUrl('active','es','standalone'));const widget=page.locator('domus-administration');
+ for(const name of ['Editar hogar','Editar preferencias','Editar reglas de acceso','Gestionar participante: Fictional pending participant'])
+ await widget.getByRole('button',{name,exact:true}).click();
+ await expect(widget.getByLabel('Nuevo nombre del hogar',{exact:true})).toHaveValue('Maple House');
+ expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(360);
+ const contrast=await widget.locator('button.primary').first().evaluate(button=>{
+ const style=getComputedStyle(button);const luminance=(rgb:string)=>{
+ const v=rgb.match(/[\d.]+/gu)!.slice(0,3).map(Number).map(value=>{const x=value/255;return x<=.04045?x/12.92:((x+.055)/1.055)**2.4;});
+ return v[0]*.2126+v[1]*.7152+v[2]*.0722;};
+ const a=luminance(style.color),b=luminance(style.backgroundColor);return (Math.max(a,b)+.05)/(Math.min(a,b)+.05);
+ });expect(contrast).toBeGreaterThanOrEqual(4.5);
+ await page.screenshot({path:`test-results/administration-es-360-${colorScheme}.png`,fullPage:true});
+ }
+});
diff --git a/frontend/tests/page.test.ts b/frontend/tests/page.test.ts
index be8d8c5..7ac1c07 100644
--- a/frontend/tests/page.test.ts
+++ b/frontend/tests/page.test.ts
@@ -1,7 +1,8 @@
import {afterEach, expect, test, vi} from 'vitest';
-import {decodeIntent, mountEntry} from '../src/composition/entry-page';
+import {decodeIntent, decodeAdministrationIntent, mountEntry} from '../src/composition/entry-page';
import type {EntryPage} from '../src/composition/entry-page';
import type {DomusApp} from '../src/presentation/domus-app';
+import type {DomusAdministration} from '../src/presentation/domus-administration';
import {ENTRY_JOURNAL_KEY} from '../src/adapters/session-journal';
import corpus from './fixtures/entry-wire.json';
@@ -17,15 +18,39 @@ function fixture(deployment: 'native' | 'standalone' = 'standalone', locale = 'e
const data = document.createElement('script'); data.id = 'domus-bootstrap'; data.type = 'application/json';
data.textContent = JSON.stringify({version: 1, deployment, application_base: wire.endpoint.applicationBase, style_nonce: 'a'.repeat(32)});
document.head.append(data); nodes.push(data);
- let phase = 'unconfigured', postRequests = 0;
+ let phase = 'unconfigured', postRequests = 0, putRequests = 0;
+ const originalAccess=JSON.parse(wire.active);
+ let preferenceReceipt:Record|null=null;
+ let memberId:string=originalAccess.member.member_id, preferenceLocale:'en'|'es'|null=null, preferenceRevision=1, preferencesReadable=true;
+ const presentation=()=>({member_id:memberId,alias:null,revision:1});
+ const preferences=()=>({member_id:memberId,locale:preferenceLocale,revision:preferenceRevision,routine_participation_summaries:true,notification_delivery:'unavailable'});
const fetch = vi.fn(async (path: string, init: RequestInit) => {
const route = path.slice(wire.endpoint.applicationBase.length + 'api/v1/'.length);
let raw: string;
if (route === 'session') raw = phase === 'eligible' || phase === 'pending' ? wire.participantSession : wire.session;
- else if (route === 'authorization') raw = phase === 'active' ? wire.active : phase === 'eligible' ? wire.eligible : phase === 'pending' ? wire.pending : wire.unconfigured;
+ else if (route === 'authorization') raw = phase === 'active' ? JSON.stringify({...originalAccess,member:{...originalAccess.member,member_id:memberId},
+ permissions:originalAccess.permissions.filter((permission:string)=>preferencesReadable||permission!=='preferences.read')})
+ : phase === 'eligible' ? wire.eligible : phase === 'pending' ? wire.pending : wire.unconfigured;
else if (route === 'auth/csrf') raw = JSON.stringify({csrf_token: 'c'.repeat(64)});
else if (route === 'auth/logout') raw = JSON.stringify({state: 'logged_out', upstream_revoked: true,
message: 'You are signed out of Domus.', action_required: 'Sign in to continue.'});
+ else if (init.method==='PUT') {
+ if(route!=='administration/preferences')throw new Error('This fixture supports preference mutations only');
+ putRequests++;const body=JSON.parse(init.body as string);preferenceLocale=body.locale;preferenceRevision++;
+ preferenceReceipt={state:'committed',archived_by:null,operation_id:'profile-'+putRequests,created_at:JSON.parse(wire.session).context.server_time,
+ result:preferences(),message:'Saved.',action_required:'Review current state.'};
+ raw=JSON.stringify({...preferenceReceipt,notification_delivery:'unavailable'});
+ }
+ else if (route.startsWith('operations/')&&preferenceReceipt!==null)raw=JSON.stringify({...preferenceReceipt,delivery:'pending'});
+ else if (route==='administration/capabilities') raw=JSON.stringify({state:'available',contract_version:2,household_id:'home',exact_revisions:'json-integer-int64',
+ actions:['household.update','member.update','policy.update','member.presentation.update','member.preferences.update'],
+ journal:{namespace:'administration',version:1,maximum_records:8,maximum_bytes:65536,lifetime_ms:3600000}});
+ else if (route==='administration/preferences') raw=JSON.stringify(preferences());
+ else if (route==='administration/roster') raw=JSON.stringify({directory_state:'available',members:[{
+ member:{...originalAccess.member,member_id:memberId},presentation:presentation(),metadata:{state:'available',name:'Verified owner',avatar_state:'unavailable'}}]});
+ else if (route==='administration/directory') raw=JSON.stringify({state:'available',profiles:[{ha_user_id:originalAccess.member.ha_user_id,name:'Verified owner'}]});
+ else if (route==='administration/members/'+memberId+'/presentation') raw=JSON.stringify(presentation());
+ else if (route==='policy') raw=JSON.stringify({household_id:'home',revision:1,mapping:{mode:'all_ha_administrators',target_ha_user_id:null},adjustments:[]});
else if (init.method === 'POST') {postRequests++; phase = route === 'household' ? 'active' : 'pending'; raw = route === 'household' ? wire.setup : wire.enrollment;}
else raw = wire.household;
const response = new Response(raw, {headers: {'Content-Type': 'application/json', 'Cache-Control': 'no-store'}});
@@ -41,9 +66,71 @@ function fixture(deployment: 'native' | 'standalone' = 'standalone', locale = 'e
const open = async () => {const mounted = mountEntry(page); dispose.push(mounted.dispose); await mounted.ready;
const app = root.querySelector('domus-app')!; await app?.updateComplete; return {mounted, app};};
return {root, data, page, open, values, navigation, fetch, events, postRequests: () => postRequests,
+ putRequests:()=>putRequests,preferences:(locale:'en'|'es'|null,revision=1)=>{preferenceLocale=locale;preferenceRevision=revision;},
+ member:(value:string)=>{memberId=value;},preferencesReadable:(value:boolean)=>{preferencesReadable=value;},
phase: (value: string) => {phase = value;}};
}
function intent(app: DomusApp, detail: unknown) {app.dispatchEvent(new CustomEvent('domus-intent', {detail, bubbles: true, composed: true}));}
+function administrationIntent(app:DomusApp,detail:unknown){app.dispatchEvent(new CustomEvent('domus-administration-intent',{detail,bubbles:true,composed:true}));}
+
+test.each(['native','standalone'] as const)('HADB-03: %s document mounts the actual administration widget with current independently verified facts',async mode=>{
+ const f=fixture(mode);f.phase('active');const {app}=await f.open();
+ expect(app.administration?.preferences?.memberId).toBe('identity-1');
+ const widget=app.shadowRoot!.querySelector('domus-administration')!;await widget.updateComplete;
+ expect(widget.view).toEqual(app.administration);expect(widget.shadowRoot!.textContent).toContain('Verified owner');expect(f.putRequests()).toBe(0);
+});
+test('household-browser-C18: saved locale wins, a temporary language stays local, and both higher and restored lower revisions reapply current preferences',async()=>{
+ const f=fixture('standalone','en-GB');f.phase('active');f.preferences('es',4);const {app}=await f.open();
+ expect(app.language).toBe('es');expect(document.documentElement.lang).toBe('es');
+ intent(app,{type:'language',language:'en'});administrationIntent(app,{type:'refresh'});
+ await vi.waitFor(()=>expect(app.view?.busy).toBe(false));expect(app.language).toBe('en');expect(f.putRequests()).toBe(0);
+ f.preferences('es',5);administrationIntent(app,{type:'refresh'});await vi.waitFor(()=>expect(app.language).toBe('es'));
+ f.preferences('en',1);administrationIntent(app,{type:'refresh'});await vi.waitFor(()=>expect(app.language).toBe('en'));
+ expect(f.putRequests()).toBe(0);
+});
+test('household-browser-C18: unavailable own preferences use browser fallback and the same saved revision applies again when readable',async()=>{
+ const f=fixture();f.phase('active');f.preferences('es',3);const {app}=await f.open();expect(app.language).toBe('es');
+ f.preferencesReadable(false);administrationIntent(app,{type:'refresh'});await vi.waitFor(()=>expect(app.language).toBe('en'));
+ f.preferencesReadable(true);administrationIntent(app,{type:'refresh'});await vi.waitFor(()=>expect(app.language).toBe('es'));
+});
+test('household-browser-C13: replacing the same-HA member clears the prior temporary language and private preference scope',async()=>{
+ const f=fixture();f.phase('active');const {app}=await f.open();intent(app,{type:'language',language:'es'});
+ f.member('replacement');administrationIntent(app,{type:'refresh'});
+ await vi.waitFor(()=>expect(app.administration?.preferences?.memberId).toBe('replacement'));
+ expect(app.language).toBe('en');expect(document.documentElement.lang).toBe('en');expect(f.putRequests()).toBe(0);
+});
+test.each([null,{},[],{type:'other'},{type:'refresh',url:'https://foreign.invalid'},
+ {type:'submit',command:{action:'member.preferences.update',payload:{expected_revision:'1',locale:'es',routine_participation_summaries:true}},actor:'forged'},
+ {type:'submit',command:{action:'member.preferences.update',payload:{expected_revision:'1',locale:'es',routine_participation_summaries:true,member_id:'forged'}}},
+ {type:'recover',key:'../other'},{type:'dismiss',key:''}])('household-browser-C15: closed administration intent %j cannot select a principal or route',value=>{
+ expect(decodeAdministrationIntent(value)).toBeNull();
+});
+test('HADB-03: valid administration intents retain the exact immutable command and have no principal selector',()=>{
+ const command={action:'member.preferences.update',payload:{expected_revision:'9007199254740993',locale:null,routine_participation_summaries:false}};
+ expect(decodeAdministrationIntent({type:'submit',command})).toEqual({type:'submit',command});
+ expect(decodeAdministrationIntent({type:'recover',key:'original'})).toEqual({type:'recover',key:'original'});
+ expect(decodeAdministrationIntent({type:'dismiss',key:'original'})).toEqual({type:'dismiss',key:'original'});
+ expect(decodeAdministrationIntent({type:'refresh'})).toEqual({type:'refresh'});
+});
+test('HADB-03: a real mounted preference form reaches the protected HTTP adapter once and applies only freshly read saved locale',async()=>{
+ const f=fixture();f.phase('active');const {app}=await f.open(),widget=app.shadowRoot!.querySelector('domus-administration')!;
+ await widget.updateComplete;const button=(name:string)=>[...widget.shadowRoot!.querySelectorAll('button')].find(value=>value.textContent?.trim()===name)!;
+ button('Edit preferences').click();await widget.updateComplete;const select=widget.shadowRoot!.querySelector('select')!;
+ select.value='es';select.dispatchEvent(new Event('change',{bubbles:true}));await widget.updateComplete;
+ button('Review changes').click();await widget.updateComplete;expect(f.putRequests()).toBe(0);button('Save changes').click();
+ await vi.waitFor(()=>{expect(f.putRequests()).toBe(1);expect(app.administration?.preferences?.locale).toBe('es');expect(app.view?.busy).toBe(false);});
+ expect(app.language).toBe('es');expect(document.documentElement.lang).toBe('es');
+ const mutation=f.fetch.mock.calls.find(([,init])=>init.method==='PUT')!;
+ expect(mutation[0]).toBe('/api/v1/administration/preferences');
+ expect(JSON.parse(mutation[1].body as string)).toEqual({expected_revision:1,locale:'es',routine_participation_summaries:true});
+});
+test('household-browser-C17: an explicit onboarding language survives a proof tick and first membership without saving a preference',async()=>{
+ const f=fixture();let tick!:()=>void;f.page.setTimeout=callback=>{tick=callback;return 1;};f.page.clearTimeout=()=>{};
+ const {app}=await f.open();intent(app,{type:'language',language:'es'});tick();expect(app.language).toBe('es');
+ intent(app,{type:'setup',name:'Home',timeZone:'UTC'});
+ await vi.waitFor(()=>expect(app.administration?.preferences?.memberId).toBe('identity-1'));
+ expect(app.language).toBe('es');expect(app.administration?.preferences?.locale).toBeNull();expect(f.putRequests()).toBe(0);
+});
test.each(['native','standalone'] as const)('%s page wires the owned view to actual controller/adapters without automatic mutation', async deployment => {
const f = fixture(deployment, 'es-ES'), {app} = await f.open();
@@ -144,3 +231,34 @@ test('the actual module entrypoint starts safely and refuses the test runner loc
expect(document.querySelector('#domus-entry-error')).not.toBeNull(); expect(f.fetch).not.toHaveBeenCalled();
const mounted = mountEntry(f.page); dispose.push(mounted.dispose); await mounted.ready;
});
+
+test('HADB-02: owned administration events recover the actual original by protected GET and dismiss only after resolution',async()=>{
+ const f=fixture();f.phase('active');const {app}=await f.open();administrationIntent(app,{type:'submit',command:{action:'member.preferences.update',
+ payload:{expected_revision:'1',locale:'es',routine_participation_summaries:true}}});
+ await vi.waitFor(()=>{expect(app.administration?.outcome).toMatchObject({kind:'receipt',stage:'committed'});expect(app.view?.busy).toBe(false);});
+ const key=app.administration!.journal!.records[0].key,calls=f.fetch.mock.calls.length;
+ administrationIntent(app,{type:'recover',key});await vi.waitFor(()=>{expect(f.fetch.mock.calls.slice(calls).some(([path,init])=>path.endsWith('/operations/profile-1')&&init.method==='GET')).toBe(true);expect(app.view?.busy).toBe(false);});
+ expect(f.putRequests()).toBe(1);administrationIntent(app,{type:'dismiss',key});await vi.waitFor(()=>expect(app.administration?.journal?.records).toEqual([]));expect(f.putRequests()).toBe(1);
+});
+test('HADB-03: foreign and malformed administration events cannot drive the owned page',async()=>{
+ const f=fixture();f.phase('active');const {app}=await f.open(),child=document.createElement('span');app.append(child);const before=f.fetch.mock.calls.length;
+ child.dispatchEvent(new CustomEvent('domus-administration-intent',{detail:{type:'refresh'},bubbles:true}));
+ administrationIntent(app,{type:'refresh',url:'https://foreign.invalid'});app.dispatchEvent(new Event('domus-administration-intent'));await Promise.resolve();
+ expect(f.fetch).toHaveBeenCalledTimes(before);expect(f.putRequests()).toBe(0);
+});
+test('HADB-03: an exceptional owned administration event replaces controls with safe reload guidance',async()=>{
+ const f=fixture();f.phase('active');const {app}=await f.open(),event=new Event('domus-administration-intent');
+ Object.defineProperty(event,'detail',{get:()=>{throw new Error('Private malformed event source');}});app.dispatchEvent(event);
+ await vi.waitFor(()=>expect(document.querySelector('#domus-entry-error')).not.toBeNull());
+ expect(document.querySelector('#domus-entry-error')!.textContent).not.toContain('Private');expect(app.isConnected).toBe(false);expect(f.putRequests()).toBe(0);
+});
+test('household-browser-C18: temporary language persists while current own preferences are unavailable without saving them',async()=>{
+ const f=fixture();f.phase('active');const {app}=await f.open();f.preferencesReadable(false);administrationIntent(app,{type:'refresh'});
+ await vi.waitFor(()=>{expect(app.administration?.preferences).toBeNull();expect(app.view?.busy).toBe(false);});
+ intent(app,{type:'language',language:'es'});administrationIntent(app,{type:'refresh'});await vi.waitFor(()=>expect(app.view?.busy).toBe(false));
+ expect(app.language).toBe('es');expect(f.putRequests()).toBe(0);
+});
+test('household-browser-C18: a signed-out language choice has no identity or mutation side effect',async()=>{
+ const f=fixture();const {app}=await f.open();intent(app,{type:'logout'});await vi.waitFor(()=>expect(app.view?.notice).toBe('signed_out'));
+ const count=f.fetch.mock.calls.length;intent(app,{type:'language',language:'es'});expect(app.language).toBe('es');expect(f.fetch).toHaveBeenCalledTimes(count);expect(f.putRequests()).toBe(0);
+});
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index 4a82a8d..803f577 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -2,7 +2,7 @@ import {defineConfig} from 'vitest/config';
import {playwright} from '@vitest/browser-playwright';
import {completeCoverage} from '../tooling/frontend-coverage.mjs';
-export default defineConfig({optimizeDeps: {include: ['lit', 'lit/directives/live.js']}, test: {
+export default defineConfig({optimizeDeps: {include: ['lit', 'lit/directives/live.js', 'lit/directives/repeat.js']}, test: {
include: ['tests/*.test.ts'],
browser: {enabled: true, provider: playwright(), headless: true, instances: [{browser: 'chromium'}]},
coverage: completeCoverage('src', 'coverage'),
diff --git a/specs/CATALOG.md b/specs/CATALOG.md
index ced1787..f2c5ee4 100644
--- a/specs/CATALOG.md
+++ b/specs/CATALOG.md
@@ -11,7 +11,7 @@ debt or convert unknown historic intent into a design decision.
| Capability ID | Status | Scope | Primary SDD | Evidence |
|---|---|---|---|---|
| `platform-foundation` | Proposed | One local modular application serves the complete household product in both a Supervisor-managed App and standalone Docker from the first release. Home Assistant owns user authentication; Domus owns its business records. | [Local application platform and persistence](./platform-foundation.md) + 2 companion | 189 paths · 2026-11-01 |
-| `household-identity` | Proposed | Each participating person uses their own Home Assistant account. Domus associates that verified user with one household member and applies explicit backend permissions independently of an optional person entity. | [Household members, Home Assistant identity and permissions](./household-identity.md) + 4 companion | 216 paths · 2026-11-02 |
+| `household-identity` | Proposed | Each participating person uses their own Home Assistant account. Domus associates that verified user with one household member and applies explicit backend permissions independently of an optional person entity. | [Household members, Home Assistant identity and permissions](./household-identity.md) + 4 companion | 222 paths · 2026-11-02 |
| `household-tasks` | Proposed | Members create and perform household responsibilities with explicit assignments, actual performers and history. A recurring definition survives completion of each individual occurrence. | [Task definitions, occurrences and household execution](./household-tasks.md) + 1 companion | 32 paths · 2026-11-01 |
| `recurrence-calendar` | Proposed | A bounded scheduler turns task definitions into stable occurrences and a family calendar distinguishes planned start, deadline and actual completion. | [Recurrence engine and family calendar](./recurrence-calendar.md) + 2 companion | 22 paths · 2026-10-29 |
| `points-rewards` | Proposed | Task completion produces auditable point entries. Household-configurable collaboration policies determine each share and the actual award total; corrections preserve history and optional rewards never control basic participation. | [Auditable points, participation and optional rewards](./points-rewards.md) | 5 paths · 2026-11-01 |
@@ -42,9 +42,9 @@ debt or convert unknown historic intent into a design decision.
- Specifications: [`specs/household-identity.md`](./household-identity.md) · [`specs/household-administration.md`](./household-administration.md) · [`specs/household-administration-integration.md`](./household-administration-integration.md) · [`specs/household-administration-workspace.md`](./household-administration-workspace.md) · [`specs/household-administration-browser.md`](./household-administration-browser.md)
- Workflows: Not applicable for this capability.
- Entrypoints: [`backend/src/domus/composition/server.py`](../backend/src/domus/composition/server.py) · [`backend/src/domus/entrypoints/http.py`](../backend/src/domus/entrypoints/http.py) · [`backend/src/domus/composition/ingress.py`](../backend/src/domus/composition/ingress.py) · [`backend/src/domus/entrypoints/ingress_http.py`](../backend/src/domus/entrypoints/ingress_http.py) · [`backend/src/domus/entrypoints/household_http.py`](../backend/src/domus/entrypoints/household_http.py) · [`backend/src/domus/composition/household.py`](../backend/src/domus/composition/household.py) · [`backend/src/domus/entrypoints/native_http.py`](../backend/src/domus/entrypoints/native_http.py) · [`backend/src/domus/composition/admin.py`](../backend/src/domus/composition/admin.py) · [`backend/src/domus/entrypoints/console_recovery.py`](../backend/src/domus/entrypoints/console_recovery.py) · [`backend/src/domus/composition/ha_app.py`](../backend/src/domus/composition/ha_app.py) · [`backend/src/domus/entrypoints/administration_http.py`](../backend/src/domus/entrypoints/administration_http.py)
-- Core code: [`backend/src/domus/domain/identity.py`](../backend/src/domus/domain/identity.py) · [`backend/src/domus/application/authorization.py`](../backend/src/domus/application/authorization.py) · [`backend/src/domus/application/identity_ports.py`](../backend/src/domus/application/identity_ports.py) · [`backend/src/domus/application/sessions.py`](../backend/src/domus/application/sessions.py) · [`backend/src/domus/infrastructure/sqlite_identity.py`](../backend/src/domus/infrastructure/sqlite_identity.py) · [`backend/src/domus/infrastructure/memory_sessions.py`](../backend/src/domus/infrastructure/memory_sessions.py) · [`backend/src/domus/infrastructure/ha_config.py`](../backend/src/domus/infrastructure/ha_config.py) · [`backend/src/domus/infrastructure/ha_http.py`](../backend/src/domus/infrastructure/ha_http.py) · [`backend/src/domus/infrastructure/ha_websocket.py`](../backend/src/domus/infrastructure/ha_websocket.py) · [`backend/src/domus/infrastructure/ha_ingress.py`](../backend/src/domus/infrastructure/ha_ingress.py) · [`backend/src/domus/application/authentication.py`](../backend/src/domus/application/authentication.py) · [`backend/src/domus/application/authentication_values.py`](../backend/src/domus/application/authentication_values.py) · [`backend/src/domus/composition/server.py`](../backend/src/domus/composition/server.py) · [`backend/src/domus/entrypoints/http.py`](../backend/src/domus/entrypoints/http.py) · [`backend/src/domus/infrastructure/auth_runtime.py`](../backend/src/domus/infrastructure/auth_runtime.py) · [`backend/src/domus/infrastructure/memory_attempts.py`](../backend/src/domus/infrastructure/memory_attempts.py) · [`backend/src/domus/infrastructure/standalone_sessions.py`](../backend/src/domus/infrastructure/standalone_sessions.py) · [`backend/src/domus/application/session_values.py`](../backend/src/domus/application/session_values.py) · [`backend/src/domus/composition/ingress.py`](../backend/src/domus/composition/ingress.py) · [`backend/src/domus/entrypoints/ingress_http.py`](../backend/src/domus/entrypoints/ingress_http.py) · [`backend/src/domus/entrypoints/bounded_http.py`](../backend/src/domus/entrypoints/bounded_http.py) · [`backend/src/domus/application/household_commands.py`](../backend/src/domus/application/household_commands.py) · [`backend/src/domus/infrastructure/application_runtime.py`](../backend/src/domus/infrastructure/application_runtime.py) · [`backend/src/domus/infrastructure/ha_directory.py`](../backend/src/domus/infrastructure/ha_directory.py) · [`backend/src/domus/application/member_commands.py`](../backend/src/domus/application/member_commands.py) · [`backend/src/domus/application/administrator_proof.py`](../backend/src/domus/application/administrator_proof.py) · [`backend/src/domus/application/policy_commands.py`](../backend/src/domus/application/policy_commands.py) · [`backend/src/domus/application/household_queries.py`](../backend/src/domus/application/household_queries.py) · [`backend/src/domus/entrypoints/household_http.py`](../backend/src/domus/entrypoints/household_http.py) · [`backend/src/domus/entrypoints/http_contract.py`](../backend/src/domus/entrypoints/http_contract.py) · [`backend/src/domus/composition/household.py`](../backend/src/domus/composition/household.py) · [`backend/src/domus/infrastructure/session_access.py`](../backend/src/domus/infrastructure/session_access.py) · [`backend/src/domus/entrypoints/native_http.py`](../backend/src/domus/entrypoints/native_http.py) · [`backend/src/domus/entrypoints/http_errors.py`](../backend/src/domus/entrypoints/http_errors.py) · [`backend/src/domus/entrypoints/ingress_boundary.py`](../backend/src/domus/entrypoints/ingress_boundary.py) · [`backend/src/domus/infrastructure/ingress_sessions.py`](../backend/src/domus/infrastructure/ingress_sessions.py) · [`backend/src/domus/infrastructure/owned_storage.py`](../backend/src/domus/infrastructure/owned_storage.py) · [`backend/src/domus/domain/recovery.py`](../backend/src/domus/domain/recovery.py) · [`backend/src/domus/application/recovery_commands.py`](../backend/src/domus/application/recovery_commands.py) · [`backend/src/domus/infrastructure/sqlite_recovery.py`](../backend/src/domus/infrastructure/sqlite_recovery.py) · [`backend/src/domus/composition/admin.py`](../backend/src/domus/composition/admin.py) · [`backend/src/domus/entrypoints/console_recovery.py`](../backend/src/domus/entrypoints/console_recovery.py) · [`backend/src/domus/domain/snapshots.py`](../backend/src/domus/domain/snapshots.py) · [`backend/src/domus/application/snapshots.py`](../backend/src/domus/application/snapshots.py) · [`backend/src/domus/infrastructure/sqlite_snapshots.py`](../backend/src/domus/infrastructure/sqlite_snapshots.py) · [`backend/src/domus/composition/ha_app.py`](../backend/src/domus/composition/ha_app.py) · [`backend/src/domus/infrastructure/sqlite_history.py`](../backend/src/domus/infrastructure/sqlite_history.py) · [`backend/src/domus/domain/generations.py`](../backend/src/domus/domain/generations.py) · [`backend/src/domus/domain/activation.py`](../backend/src/domus/domain/activation.py) · [`backend/src/domus/application/activation.py`](../backend/src/domus/application/activation.py) · [`backend/src/domus/composition/restore.py`](../backend/src/domus/composition/restore.py) · [`backend/src/domus/infrastructure/offline_storage.py`](../backend/src/domus/infrastructure/offline_storage.py) · [`backend/src/domus/infrastructure/restore_source.py`](../backend/src/domus/infrastructure/restore_source.py) · [`backend/src/domus/infrastructure/sqlite_activation.py`](../backend/src/domus/infrastructure/sqlite_activation.py) · [`backend/src/domus/domain/member_profiles.py`](../backend/src/domus/domain/member_profiles.py) · [`backend/src/domus/application/member_profile_access.py`](../backend/src/domus/application/member_profile_access.py) · [`backend/src/domus/application/member_profile_commands.py`](../backend/src/domus/application/member_profile_commands.py) · [`backend/src/domus/application/household_administration_queries.py`](../backend/src/domus/application/household_administration_queries.py) · [`backend/src/domus/composition/household_administration.py`](../backend/src/domus/composition/household_administration.py) · [`backend/src/domus/entrypoints/administration_dto.py`](../backend/src/domus/entrypoints/administration_dto.py) · [`backend/src/domus/entrypoints/administration_http.py`](../backend/src/domus/entrypoints/administration_http.py) · [`backend/src/domus/entrypoints/openapi.py`](../backend/src/domus/entrypoints/openapi.py) · [`frontend/src/domain/administration.ts`](../frontend/src/domain/administration.ts) · [`frontend/src/adapters/administration-json.ts`](../frontend/src/adapters/administration-json.ts) · [`backend/src/domus/domain/member_sources.py`](../backend/src/domus/domain/member_sources.py) · [`backend/src/domus/application/member_source_ports.py`](../backend/src/domus/application/member_source_ports.py) · [`backend/src/domus/application/household_roster.py`](../backend/src/domus/application/household_roster.py) · [`backend/src/domus/infrastructure/ha_member_profiles.py`](../backend/src/domus/infrastructure/ha_member_profiles.py) · [`frontend/src/domain/administration-commands.ts`](../frontend/src/domain/administration-commands.ts) · [`frontend/src/domain/administration-journal.ts`](../frontend/src/domain/administration-journal.ts) · [`frontend/src/application/administration-ports.ts`](../frontend/src/application/administration-ports.ts) · [`frontend/src/adapters/administration-journal.ts`](../frontend/src/adapters/administration-journal.ts) · [`frontend/src/domain/administration-views.ts`](../frontend/src/domain/administration-views.ts) · [`frontend/src/adapters/json-values.ts`](../frontend/src/adapters/json-values.ts) · [`frontend/src/adapters/http-transport.ts`](../frontend/src/adapters/http-transport.ts) · [`frontend/src/adapters/administration-values.ts`](../frontend/src/adapters/administration-values.ts) · [`frontend/src/adapters/administration-http.ts`](../frontend/src/adapters/administration-http.ts) · [`frontend/src/application/administration-controller.ts`](../frontend/src/application/administration-controller.ts) · [`frontend/src/domain/administration-authorization.ts`](../frontend/src/domain/administration-authorization.ts) · [`frontend/src/application/household-controller.ts`](../frontend/src/application/household-controller.ts)
-- Tests: [`tests/backend/test_identity_policy.py`](../tests/backend/test_identity_policy.py) · [`tests/backend/test_membership_authorization.py`](../tests/backend/test_membership_authorization.py) · [`tests/backend/test_session_leases.py`](../tests/backend/test_session_leases.py) · [`tests/backend/test_sqlite_identity.py`](../tests/backend/test_sqlite_identity.py) · [`tests/backend/test_ha_http.py`](../tests/backend/test_ha_http.py) · [`tests/backend/test_ha_websocket.py`](../tests/backend/test_ha_websocket.py) · [`tests/backend/test_ha_ingress.py`](../tests/backend/test_ha_ingress.py) · [`tests/backend/test_authentication_flow.py`](../tests/backend/test_authentication_flow.py) · [`tests/backend/test_standalone_sessions.py`](../tests/backend/test_standalone_sessions.py) · [`tests/backend/test_auth_http.py`](../tests/backend/test_auth_http.py) · [`tests/backend/test_auth_runtime.py`](../tests/backend/test_auth_runtime.py) · [`tests/backend/test_auth_server.py`](../tests/backend/test_auth_server.py) · [`tests/backend/test_ingress_http.py`](../tests/backend/test_ingress_http.py) · [`tests/backend/test_ingress_server.py`](../tests/backend/test_ingress_server.py) · [`tests/backend/test_household_commands.py`](../tests/backend/test_household_commands.py) · [`tests/backend/test_application_runtime.py`](../tests/backend/test_application_runtime.py) · [`tests/backend/test_ha_directory.py`](../tests/backend/test_ha_directory.py) · [`tests/backend/test_member_commands.py`](../tests/backend/test_member_commands.py) · [`tests/backend/test_policy_commands.py`](../tests/backend/test_policy_commands.py) · [`tests/backend/test_household_queries.py`](../tests/backend/test_household_queries.py) · [`tests/backend/test_household_http.py`](../tests/backend/test_household_http.py) · [`tests/backend/test_household_runtime.py`](../tests/backend/test_household_runtime.py) · [`tests/backend/test_session_directory.py`](../tests/backend/test_session_directory.py) · [`tests/backend/test_ingress_sessions.py`](../tests/backend/test_ingress_sessions.py) · [`tests/backend/test_native_http.py`](../tests/backend/test_native_http.py) · [`tests/backend/test_owned_storage.py`](../tests/backend/test_owned_storage.py) · [`tests/backend/test_recovery_values.py`](../tests/backend/test_recovery_values.py) · [`tests/backend/test_sqlite_recovery.py`](../tests/backend/test_sqlite_recovery.py) · [`tests/backend/test_recovery_commands.py`](../tests/backend/test_recovery_commands.py) · [`tests/backend/test_recovery_console.py`](../tests/backend/test_recovery_console.py) · [`tests/backend/test_recovery_http.py`](../tests/backend/test_recovery_http.py) · [`tests/fixtures/sqlite/v2.sql`](../tests/fixtures/sqlite/v2.sql) · [`tests/backend/test_snapshot_values.py`](../tests/backend/test_snapshot_values.py) · [`tests/backend/test_snapshot_commands.py`](../tests/backend/test_snapshot_commands.py) · [`tests/backend/test_sqlite_snapshots.py`](../tests/backend/test_sqlite_snapshots.py) · [`tests/backend/test_snapshot_console.py`](../tests/backend/test_snapshot_console.py) · [`tests/backend/test_ha_app_launcher.py`](../tests/backend/test_ha_app_launcher.py) · [`tests/backend/test_sqlite_history.py`](../tests/backend/test_sqlite_history.py) · [`tests/backend/test_sqlite_history_integrity.py`](../tests/backend/test_sqlite_history_integrity.py) · [`tests/backend/test_restored_commands.py`](../tests/backend/test_restored_commands.py) · [`tests/fixtures/sqlite/v3.sql`](../tests/fixtures/sqlite/v3.sql) · [`tests/backend/test_generation_values.py`](../tests/backend/test_generation_values.py) · [`tests/backend/test_owned_generations.py`](../tests/backend/test_owned_generations.py) · [`tests/backend/test_activation_values.py`](../tests/backend/test_activation_values.py) · [`tests/backend/test_activation_console.py`](../tests/backend/test_activation_console.py) · [`tests/backend/test_restore_source.py`](../tests/backend/test_restore_source.py) · [`tests/backend/test_offline_storage.py`](../tests/backend/test_offline_storage.py) · [`tests/backend/test_sqlite_activation.py`](../tests/backend/test_sqlite_activation.py) · [`tests/backend/test_activation_failures.py`](../tests/backend/test_activation_failures.py) · [`tests/backend/test_activation_compatibility.py`](../tests/backend/test_activation_compatibility.py) · [`tests/containers/test_app_image.py`](../tests/containers/test_app_image.py) · [`tests/containers/test_app_compatibility.py`](../tests/containers/test_app_compatibility.py) · [`tests/containers/restore_fixture.py`](../tests/containers/restore_fixture.py) · [`tests/backend/test_response_readability.py`](../tests/backend/test_response_readability.py) · [`tests/backend/test_review_regressions.py`](../tests/backend/test_review_regressions.py) · [`tests/backend/test_member_profiles.py`](../tests/backend/test_member_profiles.py) · [`tests/backend/test_member_profile_commands.py`](../tests/backend/test_member_profile_commands.py) · [`tests/backend/test_administration_http.py`](../tests/backend/test_administration_http.py) · [`tests/containers/test_administration_image.py`](../tests/containers/test_administration_image.py) · [`tests/backend/test_openapi.py`](../tests/backend/test_openapi.py) · [`frontend/tests/administration.test.ts`](../frontend/tests/administration.test.ts) · [`tests/backend/test_member_sources.py`](../tests/backend/test_member_sources.py) · [`tests/backend/test_household_roster.py`](../tests/backend/test_household_roster.py) · [`tests/backend/test_ha_member_profiles.py`](../tests/backend/test_ha_member_profiles.py) · [`tests/backend/test_standalone_member_sources.py`](../tests/backend/test_standalone_member_sources.py) · [`frontend/tests/administration-journal.test.ts`](../frontend/tests/administration-journal.test.ts) · [`frontend/tests/administration-session-journal.test.ts`](../frontend/tests/administration-session-journal.test.ts) · [`frontend/tests/entry-int64.test.ts`](../frontend/tests/entry-int64.test.ts) · [`frontend/tests/administration-http.test.ts`](../frontend/tests/administration-http.test.ts) · [`frontend/tests/e2e/entry.spec.ts`](../frontend/tests/e2e/entry.spec.ts) · [`tests/browser/entry_server.py`](../tests/browser/entry_server.py) · [`tests/containers/test_app_ui.py`](../tests/containers/test_app_ui.py) · [`tests/packaging/test_ui_wheel.py`](../tests/packaging/test_ui_wheel.py) · [`frontend/tests/administration-controller.test.ts`](../frontend/tests/administration-controller.test.ts) · [`frontend/tests/administration-authorization.test.ts`](../frontend/tests/administration-authorization.test.ts) · [`frontend/tests/household-controller.test.ts`](../frontend/tests/household-controller.test.ts)
-- User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) · [`docs/architecture-identity-storage.md`](../docs/architecture-identity-storage.md) · [`docs/migrations-identity-storage.md`](../docs/migrations-identity-storage.md) · [`docs/ha-identity-protocol.md`](../docs/ha-identity-protocol.md) · [`docs/identity-household.md`](../docs/identity-household.md) · [`docs/configuration.md`](../docs/configuration.md) · [`docs/operations-identity-storage.md`](../docs/operations-identity-storage.md) · [`docs/contracts/auth-api.openapi.json`](../docs/contracts/auth-api.openapi.json) · [`docs/installation-home-assistant.md`](../docs/installation-home-assistant.md) · [`docs/household-setup.md`](../docs/household-setup.md) · [`docs/evidence/m1-household-commands.json`](../docs/evidence/m1-household-commands.json) · [`docs/evidence/m1-native-ha-acceptance.json`](../docs/evidence/m1-native-ha-acceptance.json) · [`docs/evidence/m1-member-administration.json`](../docs/evidence/m1-member-administration.json) · [`docs/evidence/m1-policy-administration.json`](../docs/evidence/m1-policy-administration.json) · [`docs/household-api.md`](../docs/household-api.md) · [`docs/contracts/household-api.openapi.json`](../docs/contracts/household-api.openapi.json) · [`docs/evidence/m1-household-api.json`](../docs/evidence/m1-household-api.json) · [`docs/native-household-api.md`](../docs/native-household-api.md) · [`docs/contracts/native-household-api.openapi.json`](../docs/contracts/native-household-api.openapi.json) · [`docs/evidence/m1-native-household-api.json`](../docs/evidence/m1-native-household-api.json) · [`docs/owned-data-directory.md`](../docs/owned-data-directory.md) · [`docs/evidence/m1-owned-storage.json`](../docs/evidence/m1-owned-storage.json) · [`docs/administrator-recovery.md`](../docs/administrator-recovery.md) · [`docs/evidence/m1-administrator-recovery.json`](../docs/evidence/m1-administrator-recovery.json) · [`docs/snapshots.md`](../docs/snapshots.md) · [`docs/evidence/m1-snapshots.json`](../docs/evidence/m1-snapshots.json) · [`docs/evidence/m1-explicit-storage-boundary.json`](../docs/evidence/m1-explicit-storage-boundary.json) · [`docs/evidence/m1-standalone-owned-storage.json`](../docs/evidence/m1-standalone-owned-storage.json) · [`docs/evidence/m1-ha-app-bootstrap.json`](../docs/evidence/m1-ha-app-bootstrap.json) · [`addon-edge/translations/en.yaml`](../addon-edge/translations/en.yaml) · [`docs/evidence/m1-persistent-app-package.json`](../docs/evidence/m1-persistent-app-package.json) · [`docs/evidence/m1-persistent-ha-acceptance.json`](../docs/evidence/m1-persistent-ha-acceptance.json) · [`docs/evidence/m1-runtime-history.json`](../docs/evidence/m1-runtime-history.json) · [`docs/evidence/m1-generation-selection.json`](../docs/evidence/m1-generation-selection.json) · [`docs/evidence/m1-offline-activation.json`](../docs/evidence/m1-offline-activation.json) · [`docs/evidence/m1-restoration-app-package.json`](../docs/evidence/m1-restoration-app-package.json) · [`docs/evidence/m1-edge3-ha-acceptance.json`](../docs/evidence/m1-edge3-ha-acceptance.json) · [`docs/m1-acceptance-audit.md`](../docs/m1-acceptance-audit.md) · [`docs/evidence/m1-acceptance-audit.json`](../docs/evidence/m1-acceptance-audit.json) · [`docs/evidence/issue6-human-clarifications.json`](../docs/evidence/issue6-human-clarifications.json) · [`docs/evidence/m1-review-fixes.json`](../docs/evidence/m1-review-fixes.json) · [`docs/architecture-household-administration.md`](../docs/architecture-household-administration.md) · [`docs/evidence/household-administration.json`](../docs/evidence/household-administration.json) · [`docs/household-administration.md`](../docs/household-administration.md) · [`docs/evidence/household-administration-browser.json`](../docs/evidence/household-administration-browser.json)
+- Core code: [`backend/src/domus/domain/identity.py`](../backend/src/domus/domain/identity.py) · [`backend/src/domus/application/authorization.py`](../backend/src/domus/application/authorization.py) · [`backend/src/domus/application/identity_ports.py`](../backend/src/domus/application/identity_ports.py) · [`backend/src/domus/application/sessions.py`](../backend/src/domus/application/sessions.py) · [`backend/src/domus/infrastructure/sqlite_identity.py`](../backend/src/domus/infrastructure/sqlite_identity.py) · [`backend/src/domus/infrastructure/memory_sessions.py`](../backend/src/domus/infrastructure/memory_sessions.py) · [`backend/src/domus/infrastructure/ha_config.py`](../backend/src/domus/infrastructure/ha_config.py) · [`backend/src/domus/infrastructure/ha_http.py`](../backend/src/domus/infrastructure/ha_http.py) · [`backend/src/domus/infrastructure/ha_websocket.py`](../backend/src/domus/infrastructure/ha_websocket.py) · [`backend/src/domus/infrastructure/ha_ingress.py`](../backend/src/domus/infrastructure/ha_ingress.py) · [`backend/src/domus/application/authentication.py`](../backend/src/domus/application/authentication.py) · [`backend/src/domus/application/authentication_values.py`](../backend/src/domus/application/authentication_values.py) · [`backend/src/domus/composition/server.py`](../backend/src/domus/composition/server.py) · [`backend/src/domus/entrypoints/http.py`](../backend/src/domus/entrypoints/http.py) · [`backend/src/domus/infrastructure/auth_runtime.py`](../backend/src/domus/infrastructure/auth_runtime.py) · [`backend/src/domus/infrastructure/memory_attempts.py`](../backend/src/domus/infrastructure/memory_attempts.py) · [`backend/src/domus/infrastructure/standalone_sessions.py`](../backend/src/domus/infrastructure/standalone_sessions.py) · [`backend/src/domus/application/session_values.py`](../backend/src/domus/application/session_values.py) · [`backend/src/domus/composition/ingress.py`](../backend/src/domus/composition/ingress.py) · [`backend/src/domus/entrypoints/ingress_http.py`](../backend/src/domus/entrypoints/ingress_http.py) · [`backend/src/domus/entrypoints/bounded_http.py`](../backend/src/domus/entrypoints/bounded_http.py) · [`backend/src/domus/application/household_commands.py`](../backend/src/domus/application/household_commands.py) · [`backend/src/domus/infrastructure/application_runtime.py`](../backend/src/domus/infrastructure/application_runtime.py) · [`backend/src/domus/infrastructure/ha_directory.py`](../backend/src/domus/infrastructure/ha_directory.py) · [`backend/src/domus/application/member_commands.py`](../backend/src/domus/application/member_commands.py) · [`backend/src/domus/application/administrator_proof.py`](../backend/src/domus/application/administrator_proof.py) · [`backend/src/domus/application/policy_commands.py`](../backend/src/domus/application/policy_commands.py) · [`backend/src/domus/application/household_queries.py`](../backend/src/domus/application/household_queries.py) · [`backend/src/domus/entrypoints/household_http.py`](../backend/src/domus/entrypoints/household_http.py) · [`backend/src/domus/entrypoints/http_contract.py`](../backend/src/domus/entrypoints/http_contract.py) · [`backend/src/domus/composition/household.py`](../backend/src/domus/composition/household.py) · [`backend/src/domus/infrastructure/session_access.py`](../backend/src/domus/infrastructure/session_access.py) · [`backend/src/domus/entrypoints/native_http.py`](../backend/src/domus/entrypoints/native_http.py) · [`backend/src/domus/entrypoints/http_errors.py`](../backend/src/domus/entrypoints/http_errors.py) · [`backend/src/domus/entrypoints/ingress_boundary.py`](../backend/src/domus/entrypoints/ingress_boundary.py) · [`backend/src/domus/infrastructure/ingress_sessions.py`](../backend/src/domus/infrastructure/ingress_sessions.py) · [`backend/src/domus/infrastructure/owned_storage.py`](../backend/src/domus/infrastructure/owned_storage.py) · [`backend/src/domus/domain/recovery.py`](../backend/src/domus/domain/recovery.py) · [`backend/src/domus/application/recovery_commands.py`](../backend/src/domus/application/recovery_commands.py) · [`backend/src/domus/infrastructure/sqlite_recovery.py`](../backend/src/domus/infrastructure/sqlite_recovery.py) · [`backend/src/domus/composition/admin.py`](../backend/src/domus/composition/admin.py) · [`backend/src/domus/entrypoints/console_recovery.py`](../backend/src/domus/entrypoints/console_recovery.py) · [`backend/src/domus/domain/snapshots.py`](../backend/src/domus/domain/snapshots.py) · [`backend/src/domus/application/snapshots.py`](../backend/src/domus/application/snapshots.py) · [`backend/src/domus/infrastructure/sqlite_snapshots.py`](../backend/src/domus/infrastructure/sqlite_snapshots.py) · [`backend/src/domus/composition/ha_app.py`](../backend/src/domus/composition/ha_app.py) · [`backend/src/domus/infrastructure/sqlite_history.py`](../backend/src/domus/infrastructure/sqlite_history.py) · [`backend/src/domus/domain/generations.py`](../backend/src/domus/domain/generations.py) · [`backend/src/domus/domain/activation.py`](../backend/src/domus/domain/activation.py) · [`backend/src/domus/application/activation.py`](../backend/src/domus/application/activation.py) · [`backend/src/domus/composition/restore.py`](../backend/src/domus/composition/restore.py) · [`backend/src/domus/infrastructure/offline_storage.py`](../backend/src/domus/infrastructure/offline_storage.py) · [`backend/src/domus/infrastructure/restore_source.py`](../backend/src/domus/infrastructure/restore_source.py) · [`backend/src/domus/infrastructure/sqlite_activation.py`](../backend/src/domus/infrastructure/sqlite_activation.py) · [`backend/src/domus/domain/member_profiles.py`](../backend/src/domus/domain/member_profiles.py) · [`backend/src/domus/application/member_profile_access.py`](../backend/src/domus/application/member_profile_access.py) · [`backend/src/domus/application/member_profile_commands.py`](../backend/src/domus/application/member_profile_commands.py) · [`backend/src/domus/application/household_administration_queries.py`](../backend/src/domus/application/household_administration_queries.py) · [`backend/src/domus/composition/household_administration.py`](../backend/src/domus/composition/household_administration.py) · [`backend/src/domus/entrypoints/administration_dto.py`](../backend/src/domus/entrypoints/administration_dto.py) · [`backend/src/domus/entrypoints/administration_http.py`](../backend/src/domus/entrypoints/administration_http.py) · [`backend/src/domus/entrypoints/openapi.py`](../backend/src/domus/entrypoints/openapi.py) · [`frontend/src/domain/administration.ts`](../frontend/src/domain/administration.ts) · [`frontend/src/adapters/administration-json.ts`](../frontend/src/adapters/administration-json.ts) · [`backend/src/domus/domain/member_sources.py`](../backend/src/domus/domain/member_sources.py) · [`backend/src/domus/application/member_source_ports.py`](../backend/src/domus/application/member_source_ports.py) · [`backend/src/domus/application/household_roster.py`](../backend/src/domus/application/household_roster.py) · [`backend/src/domus/infrastructure/ha_member_profiles.py`](../backend/src/domus/infrastructure/ha_member_profiles.py) · [`frontend/src/domain/administration-commands.ts`](../frontend/src/domain/administration-commands.ts) · [`frontend/src/domain/administration-journal.ts`](../frontend/src/domain/administration-journal.ts) · [`frontend/src/application/administration-ports.ts`](../frontend/src/application/administration-ports.ts) · [`frontend/src/adapters/administration-journal.ts`](../frontend/src/adapters/administration-journal.ts) · [`frontend/src/domain/administration-views.ts`](../frontend/src/domain/administration-views.ts) · [`frontend/src/adapters/json-values.ts`](../frontend/src/adapters/json-values.ts) · [`frontend/src/adapters/http-transport.ts`](../frontend/src/adapters/http-transport.ts) · [`frontend/src/adapters/administration-values.ts`](../frontend/src/adapters/administration-values.ts) · [`frontend/src/adapters/administration-http.ts`](../frontend/src/adapters/administration-http.ts) · [`frontend/src/application/administration-controller.ts`](../frontend/src/application/administration-controller.ts) · [`frontend/src/domain/administration-authorization.ts`](../frontend/src/domain/administration-authorization.ts) · [`frontend/src/application/household-controller.ts`](../frontend/src/application/household-controller.ts) · [`frontend/src/presentation/domus-administration.ts`](../frontend/src/presentation/domus-administration.ts) · [`frontend/src/presentation/administration-messages.ts`](../frontend/src/presentation/administration-messages.ts)
+- Tests: [`tests/backend/test_identity_policy.py`](../tests/backend/test_identity_policy.py) · [`tests/backend/test_membership_authorization.py`](../tests/backend/test_membership_authorization.py) · [`tests/backend/test_session_leases.py`](../tests/backend/test_session_leases.py) · [`tests/backend/test_sqlite_identity.py`](../tests/backend/test_sqlite_identity.py) · [`tests/backend/test_ha_http.py`](../tests/backend/test_ha_http.py) · [`tests/backend/test_ha_websocket.py`](../tests/backend/test_ha_websocket.py) · [`tests/backend/test_ha_ingress.py`](../tests/backend/test_ha_ingress.py) · [`tests/backend/test_authentication_flow.py`](../tests/backend/test_authentication_flow.py) · [`tests/backend/test_standalone_sessions.py`](../tests/backend/test_standalone_sessions.py) · [`tests/backend/test_auth_http.py`](../tests/backend/test_auth_http.py) · [`tests/backend/test_auth_runtime.py`](../tests/backend/test_auth_runtime.py) · [`tests/backend/test_auth_server.py`](../tests/backend/test_auth_server.py) · [`tests/backend/test_ingress_http.py`](../tests/backend/test_ingress_http.py) · [`tests/backend/test_ingress_server.py`](../tests/backend/test_ingress_server.py) · [`tests/backend/test_household_commands.py`](../tests/backend/test_household_commands.py) · [`tests/backend/test_application_runtime.py`](../tests/backend/test_application_runtime.py) · [`tests/backend/test_ha_directory.py`](../tests/backend/test_ha_directory.py) · [`tests/backend/test_member_commands.py`](../tests/backend/test_member_commands.py) · [`tests/backend/test_policy_commands.py`](../tests/backend/test_policy_commands.py) · [`tests/backend/test_household_queries.py`](../tests/backend/test_household_queries.py) · [`tests/backend/test_household_http.py`](../tests/backend/test_household_http.py) · [`tests/backend/test_household_runtime.py`](../tests/backend/test_household_runtime.py) · [`tests/backend/test_session_directory.py`](../tests/backend/test_session_directory.py) · [`tests/backend/test_ingress_sessions.py`](../tests/backend/test_ingress_sessions.py) · [`tests/backend/test_native_http.py`](../tests/backend/test_native_http.py) · [`tests/backend/test_owned_storage.py`](../tests/backend/test_owned_storage.py) · [`tests/backend/test_recovery_values.py`](../tests/backend/test_recovery_values.py) · [`tests/backend/test_sqlite_recovery.py`](../tests/backend/test_sqlite_recovery.py) · [`tests/backend/test_recovery_commands.py`](../tests/backend/test_recovery_commands.py) · [`tests/backend/test_recovery_console.py`](../tests/backend/test_recovery_console.py) · [`tests/backend/test_recovery_http.py`](../tests/backend/test_recovery_http.py) · [`tests/fixtures/sqlite/v2.sql`](../tests/fixtures/sqlite/v2.sql) · [`tests/backend/test_snapshot_values.py`](../tests/backend/test_snapshot_values.py) · [`tests/backend/test_snapshot_commands.py`](../tests/backend/test_snapshot_commands.py) · [`tests/backend/test_sqlite_snapshots.py`](../tests/backend/test_sqlite_snapshots.py) · [`tests/backend/test_snapshot_console.py`](../tests/backend/test_snapshot_console.py) · [`tests/backend/test_ha_app_launcher.py`](../tests/backend/test_ha_app_launcher.py) · [`tests/backend/test_sqlite_history.py`](../tests/backend/test_sqlite_history.py) · [`tests/backend/test_sqlite_history_integrity.py`](../tests/backend/test_sqlite_history_integrity.py) · [`tests/backend/test_restored_commands.py`](../tests/backend/test_restored_commands.py) · [`tests/fixtures/sqlite/v3.sql`](../tests/fixtures/sqlite/v3.sql) · [`tests/backend/test_generation_values.py`](../tests/backend/test_generation_values.py) · [`tests/backend/test_owned_generations.py`](../tests/backend/test_owned_generations.py) · [`tests/backend/test_activation_values.py`](../tests/backend/test_activation_values.py) · [`tests/backend/test_activation_console.py`](../tests/backend/test_activation_console.py) · [`tests/backend/test_restore_source.py`](../tests/backend/test_restore_source.py) · [`tests/backend/test_offline_storage.py`](../tests/backend/test_offline_storage.py) · [`tests/backend/test_sqlite_activation.py`](../tests/backend/test_sqlite_activation.py) · [`tests/backend/test_activation_failures.py`](../tests/backend/test_activation_failures.py) · [`tests/backend/test_activation_compatibility.py`](../tests/backend/test_activation_compatibility.py) · [`tests/containers/test_app_image.py`](../tests/containers/test_app_image.py) · [`tests/containers/test_app_compatibility.py`](../tests/containers/test_app_compatibility.py) · [`tests/containers/restore_fixture.py`](../tests/containers/restore_fixture.py) · [`tests/backend/test_response_readability.py`](../tests/backend/test_response_readability.py) · [`tests/backend/test_review_regressions.py`](../tests/backend/test_review_regressions.py) · [`tests/backend/test_member_profiles.py`](../tests/backend/test_member_profiles.py) · [`tests/backend/test_member_profile_commands.py`](../tests/backend/test_member_profile_commands.py) · [`tests/backend/test_administration_http.py`](../tests/backend/test_administration_http.py) · [`tests/containers/test_administration_image.py`](../tests/containers/test_administration_image.py) · [`tests/backend/test_openapi.py`](../tests/backend/test_openapi.py) · [`frontend/tests/administration.test.ts`](../frontend/tests/administration.test.ts) · [`tests/backend/test_member_sources.py`](../tests/backend/test_member_sources.py) · [`tests/backend/test_household_roster.py`](../tests/backend/test_household_roster.py) · [`tests/backend/test_ha_member_profiles.py`](../tests/backend/test_ha_member_profiles.py) · [`tests/backend/test_standalone_member_sources.py`](../tests/backend/test_standalone_member_sources.py) · [`frontend/tests/administration-journal.test.ts`](../frontend/tests/administration-journal.test.ts) · [`frontend/tests/administration-session-journal.test.ts`](../frontend/tests/administration-session-journal.test.ts) · [`frontend/tests/entry-int64.test.ts`](../frontend/tests/entry-int64.test.ts) · [`frontend/tests/administration-http.test.ts`](../frontend/tests/administration-http.test.ts) · [`frontend/tests/e2e/entry.spec.ts`](../frontend/tests/e2e/entry.spec.ts) · [`tests/browser/entry_server.py`](../tests/browser/entry_server.py) · [`tests/containers/test_app_ui.py`](../tests/containers/test_app_ui.py) · [`tests/packaging/test_ui_wheel.py`](../tests/packaging/test_ui_wheel.py) · [`frontend/tests/administration-controller.test.ts`](../frontend/tests/administration-controller.test.ts) · [`frontend/tests/administration-authorization.test.ts`](../frontend/tests/administration-authorization.test.ts) · [`frontend/tests/household-controller.test.ts`](../frontend/tests/household-controller.test.ts) · [`frontend/tests/administration-component.test.ts`](../frontend/tests/administration-component.test.ts) · [`frontend/tests/page.test.ts`](../frontend/tests/page.test.ts) · [`frontend/tests/e2e/administration.spec.ts`](../frontend/tests/e2e/administration.spec.ts)
+- User documentation: [`docs/product-vision.md`](../docs/product-vision.md) · [`docs/architecture.md`](../docs/architecture.md) · [`docs/operations.md`](../docs/operations.md) · [`docs/user-guide.md`](../docs/user-guide.md) · [`docs/contracts/home-assistant.md`](../docs/contracts/home-assistant.md) · [`docs/architecture-identity-storage.md`](../docs/architecture-identity-storage.md) · [`docs/migrations-identity-storage.md`](../docs/migrations-identity-storage.md) · [`docs/ha-identity-protocol.md`](../docs/ha-identity-protocol.md) · [`docs/identity-household.md`](../docs/identity-household.md) · [`docs/configuration.md`](../docs/configuration.md) · [`docs/operations-identity-storage.md`](../docs/operations-identity-storage.md) · [`docs/contracts/auth-api.openapi.json`](../docs/contracts/auth-api.openapi.json) · [`docs/installation-home-assistant.md`](../docs/installation-home-assistant.md) · [`docs/household-setup.md`](../docs/household-setup.md) · [`docs/evidence/m1-household-commands.json`](../docs/evidence/m1-household-commands.json) · [`docs/evidence/m1-native-ha-acceptance.json`](../docs/evidence/m1-native-ha-acceptance.json) · [`docs/evidence/m1-member-administration.json`](../docs/evidence/m1-member-administration.json) · [`docs/evidence/m1-policy-administration.json`](../docs/evidence/m1-policy-administration.json) · [`docs/household-api.md`](../docs/household-api.md) · [`docs/contracts/household-api.openapi.json`](../docs/contracts/household-api.openapi.json) · [`docs/evidence/m1-household-api.json`](../docs/evidence/m1-household-api.json) · [`docs/native-household-api.md`](../docs/native-household-api.md) · [`docs/contracts/native-household-api.openapi.json`](../docs/contracts/native-household-api.openapi.json) · [`docs/evidence/m1-native-household-api.json`](../docs/evidence/m1-native-household-api.json) · [`docs/owned-data-directory.md`](../docs/owned-data-directory.md) · [`docs/evidence/m1-owned-storage.json`](../docs/evidence/m1-owned-storage.json) · [`docs/administrator-recovery.md`](../docs/administrator-recovery.md) · [`docs/evidence/m1-administrator-recovery.json`](../docs/evidence/m1-administrator-recovery.json) · [`docs/snapshots.md`](../docs/snapshots.md) · [`docs/evidence/m1-snapshots.json`](../docs/evidence/m1-snapshots.json) · [`docs/evidence/m1-explicit-storage-boundary.json`](../docs/evidence/m1-explicit-storage-boundary.json) · [`docs/evidence/m1-standalone-owned-storage.json`](../docs/evidence/m1-standalone-owned-storage.json) · [`docs/evidence/m1-ha-app-bootstrap.json`](../docs/evidence/m1-ha-app-bootstrap.json) · [`addon-edge/translations/en.yaml`](../addon-edge/translations/en.yaml) · [`docs/evidence/m1-persistent-app-package.json`](../docs/evidence/m1-persistent-app-package.json) · [`docs/evidence/m1-persistent-ha-acceptance.json`](../docs/evidence/m1-persistent-ha-acceptance.json) · [`docs/evidence/m1-runtime-history.json`](../docs/evidence/m1-runtime-history.json) · [`docs/evidence/m1-generation-selection.json`](../docs/evidence/m1-generation-selection.json) · [`docs/evidence/m1-offline-activation.json`](../docs/evidence/m1-offline-activation.json) · [`docs/evidence/m1-restoration-app-package.json`](../docs/evidence/m1-restoration-app-package.json) · [`docs/evidence/m1-edge3-ha-acceptance.json`](../docs/evidence/m1-edge3-ha-acceptance.json) · [`docs/m1-acceptance-audit.md`](../docs/m1-acceptance-audit.md) · [`docs/evidence/m1-acceptance-audit.json`](../docs/evidence/m1-acceptance-audit.json) · [`docs/evidence/issue6-human-clarifications.json`](../docs/evidence/issue6-human-clarifications.json) · [`docs/evidence/m1-review-fixes.json`](../docs/evidence/m1-review-fixes.json) · [`docs/architecture-household-administration.md`](../docs/architecture-household-administration.md) · [`docs/evidence/household-administration.json`](../docs/evidence/household-administration.json) · [`docs/household-administration.md`](../docs/household-administration.md) · [`docs/evidence/household-administration-browser.json`](../docs/evidence/household-administration-browser.json) · [`docs/household-administration-operations.md`](../docs/household-administration-operations.md)
### `household-tasks` — Task definitions, occurrences and household execution
diff --git a/specs/catalog.json b/specs/catalog.json
index cad4435..8960d68 100644
--- a/specs/catalog.json
+++ b/specs/catalog.json
@@ -327,7 +327,9 @@
"frontend/src/adapters/administration-http.ts",
"frontend/src/application/administration-controller.ts",
"frontend/src/domain/administration-authorization.ts",
- "frontend/src/application/household-controller.ts"
+ "frontend/src/application/household-controller.ts",
+ "frontend/src/presentation/domus-administration.ts",
+ "frontend/src/presentation/administration-messages.ts"
],
"tests": [
"tests/backend/test_identity_policy.py",
@@ -405,7 +407,10 @@
"tests/packaging/test_ui_wheel.py",
"frontend/tests/administration-controller.test.ts",
"frontend/tests/administration-authorization.test.ts",
- "frontend/tests/household-controller.test.ts"
+ "frontend/tests/household-controller.test.ts",
+ "frontend/tests/administration-component.test.ts",
+ "frontend/tests/page.test.ts",
+ "frontend/tests/e2e/administration.spec.ts"
],
"documentation": [
"docs/product-vision.md",
@@ -456,7 +461,8 @@
"docs/architecture-household-administration.md",
"docs/evidence/household-administration.json",
"docs/household-administration.md",
- "docs/evidence/household-administration-browser.json"
+ "docs/evidence/household-administration-browser.json",
+ "docs/household-administration-operations.md"
],
"readiness": {
"state": "draft",
diff --git a/specs/household-administration-browser.md b/specs/household-administration-browser.md
index 5583f5c..a93fe29 100644
--- a/specs/household-administration-browser.md
+++ b/specs/household-administration-browser.md
@@ -350,12 +350,16 @@ Tests first. Preserve repository-wide supported coverage, complete global and pe
| `frontend/src/presentation/administration-messages.ts` | create |
| `frontend/src/presentation/messages.ts` | update |
| `frontend/src/presentation/styles.ts` | update |
+| `frontend/vitest.config.ts` | update |
+| `frontend/playwright.entry.config.ts` | update |
+| `frontend/tests/e2e/catalog.spec.ts` | update |
| `frontend/tests/household-controller.test.ts` | create |
| `frontend/tests/controller.test.ts` | update |
| `frontend/tests/runtime.test.ts` | update |
| `frontend/tests/page.test.ts` | update |
| `frontend/tests/component.test.ts` | update |
| `frontend/tests/administration-component.test.ts` | create |
+| `frontend/tests/administration-controller.test.ts` | update |
| `frontend/tests/fixtures/entry-wire.json` | update |
| `frontend/tests/e2e/administration.spec.ts` | create |
| `frontend/stories/administration.stories.ts` | create |
From e7d090ba6bc4a0968b6813eef80d78575bcb424a Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Tue, 3 Nov 2026 09:45:49 +0100
Subject: [PATCH 07/27] test: Register the verified administration catalog in
CI (#27)
---
.../household-administration-browser.json | 28 ++++++++++++++++++-
specs/household-administration-browser.md | 1 +
tests/python/test_ci_contract.py | 1 +
3 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/docs/evidence/household-administration-browser.json b/docs/evidence/household-administration-browser.json
index c37a9c8..c5bc3ba 100644
--- a/docs/evidence/household-administration-browser.json
+++ b/docs/evidence/household-administration-browser.json
@@ -262,7 +262,7 @@
"root": "/private/tmp/domus-admin-forms-86e120a",
"privateLocalOnly": true
},
- "review": "Fresh current-head CI/CPU/native review pending publication; prior86 CI/native success remains baseline evidence only.",
+ "review": "Published3da native PR review: complete context,8/8partitions,zero candidate/published/all-state findings. Follow-up consumer inventory correction requires its own current-head CI/CPU/native review; full feature acceptance remains pending.",
"checks": {
"strictRuntimeAndCatalogTypes": "passed",
"architectureAssertions": 51,
@@ -271,6 +271,32 @@
"nodeSupportedCoveragePercent": 100,
"documentation": "passed",
"originalSDDPolicy": "seven original SDK modules byte-verified; format/consistency/catalog passed;20owner documents unchanged"
+ },
+ "consumerCatalogRegistrationCorrection": {
+ "head": "3da7e98a32c5c6ca7d2a9ba2348cfd2b05a3bdb7",
+ "run": 41604593960,
+ "job": 125811179615,
+ "original": "147passed1failed: new administration Storybook file absent from the explicit tested catalog inventory. Reproduced locally before correction.",
+ "change": "Register only frontend/stories/administration.stories.ts alongside the existing entry story. Actual25-case built catalog and strict runtime/catalog type gates already cover it. No coverage prefix/exclusion/threshold changes.",
+ "confirmed": "148Python tooling tests;214statements/98branches100%;Ruff passes"
+ },
+ "nativePublished3da": {
+ "run": 41604593997,
+ "job": 125811180905,
+ "head": "3da7e98a32c5c6ca7d2a9ba2348cfd2b05a3bdb7",
+ "context": "complete",
+ "partitions": "8/8",
+ "candidateFindings": 0,
+ "publishedFindings": 0,
+ "outcome": "no-findings"
+ },
+ "copilotProgressPublished3da": {
+ "run": 41604586557,
+ "job": 125811154753,
+ "scopeFailure": "scope-clause-limit;$.lines;requirement none;L140",
+ "reference": "0c02fe42-908d-49b2-8c0b-fd5b99203305",
+ "report": "https://github.com/vypdev/copilot/issues/828#issuecomment-6599193241",
+ "scope": "Separate progress-contract failure; no Copilot change or inferred candidate/root cause."
}
}
}
diff --git a/specs/household-administration-browser.md b/specs/household-administration-browser.md
index a93fe29..c408612 100644
--- a/specs/household-administration-browser.md
+++ b/specs/household-administration-browser.md
@@ -359,6 +359,7 @@ Tests first. Preserve repository-wide supported coverage, complete global and pe
| `frontend/tests/page.test.ts` | update |
| `frontend/tests/component.test.ts` | update |
| `frontend/tests/administration-component.test.ts` | create |
+| `tests/python/test_ci_contract.py` | update |
| `frontend/tests/administration-controller.test.ts` | update |
| `frontend/tests/fixtures/entry-wire.json` | update |
| `frontend/tests/e2e/administration.spec.ts` | create |
diff --git a/tests/python/test_ci_contract.py b/tests/python/test_ci_contract.py
index 608fde7..466f8fc 100644
--- a/tests/python/test_ci_contract.py
+++ b/tests/python/test_ci_contract.py
@@ -123,6 +123,7 @@ def test_new_runtime_sources_require_an_explicit_coverage_scope() -> None:
"frontend/.storybook/main.ts",
"frontend/.storybook/preview.ts",
"frontend/stories/entry.stories.ts",
+ "frontend/stories/administration.stories.ts",
"frontend/playwright.config.ts",
"frontend/playwright.entry.config.ts",
"frontend/vite.config.ts",
From b9a2dfc8179128fb493ab49666c24967d308c979 Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Tue, 3 Nov 2026 10:19:37 +0100
Subject: [PATCH 08/27] fix: Preserve accessible administration focus and error
descriptions (#27)
---
.../household-administration-browser.json | 71 ++++++++++++++++++-
docs/evidence/household-administration.json | 14 ++--
docs/household-administration-operations.md | 2 +
.../src/presentation/domus-administration.ts | 17 ++---
.../tests/administration-component.test.ts | 17 +++++
frontend/tests/e2e/administration.spec.ts | 41 ++++++++++-
frontend/tests/e2e/catalog.spec.ts | 16 +++++
7 files changed, 161 insertions(+), 17 deletions(-)
diff --git a/docs/evidence/household-administration-browser.json b/docs/evidence/household-administration-browser.json
index c5bc3ba..3b3c756 100644
--- a/docs/evidence/household-administration-browser.json
+++ b/docs/evidence/household-administration-browser.json
@@ -130,7 +130,7 @@
},
"scope": "Original6backend/4frontend permission failures, distinct local/hosted CDP limitations with original traces/screenshots, complete current coverage/source/wheel and actual55browser checks. No owner HA used."
},
- "pending": "Complete retained HADM72/HADI8/HADW82 and24browser case audit and remaining boundary journeys, human UX review, owner HA adoption and fresh current-commit CI/CPU/native review. Recurrence23, Stable/Edge and all-domain product acceptance remain required.",
+ "pending": "Continue full retained HADM72/HADI8/HADW82/24browser acceptance mapping, remaining boundary journeys, human UX and compatible owner adoption. Current keyboard/profile continuation cases are locally exercised; new current-head CI/CPU/native gates remain required. Recurrence23 and all-domain/Stable/Edge product work remain unfinished.",
"goalComplete": false,
"acceptance": "Current1223Chromium unit executions,71compiled Python/SQLite and25built catalog cases do not substitute for complete owning-case acceptance or owner HA adoption.",
"recordedAtUTC": "2026-11-02T23:06:09.802178+00:00",
@@ -298,5 +298,74 @@
"report": "https://github.com/vypdev/copilot/issues/828#issuecomment-6599193241",
"scope": "Separate progress-contract failure; no Copilot change or inferred candidate/root cause."
}
+ },
+ "accessibilityIncrement": {
+ "sourceBase": "e7d090ba6bc4a0968b6813eef80d78575bcb424a",
+ "testsFirst": "Three original component failures: alias-error association/field focus, missing access-approval focus, and back-to-edit preference focus. Original log/screenshots retained locally.",
+ "implemented": "Stored first editor-control ID; current error descriptions linked to text/select/checkbox/rule/profile controls; validation focuses a relevant initial control or required approval; back restores the first field without resetting values.",
+ "frontend": {
+ "executions": 1226,
+ "authoredRuntimeFiles": 31,
+ "staticCSSFiles": 1,
+ "metrics": {
+ "lines": {
+ "total": 1577,
+ "covered": 1577,
+ "skipped": 0,
+ "pct": 100
+ },
+ "statements": {
+ "total": 2382,
+ "covered": 2382,
+ "skipped": 0,
+ "pct": 100
+ },
+ "functions": {
+ "total": 504,
+ "covered": 504,
+ "skipped": 0,
+ "pct": 100
+ },
+ "branches": {
+ "total": 1898,
+ "covered": 1898,
+ "skipped": 0,
+ "pct": 100
+ },
+ "branchesTrue": {
+ "total": 0,
+ "covered": 0,
+ "skipped": 0,
+ "pct": 100
+ }
+ },
+ "independentInventory": "passed"
+ },
+ "compiledBrowser": {
+ "wholeCampaign": 71,
+ "additionalTargetedCases": 2,
+ "scope": "Whole71nowincludes actual pending-to-active-to-later-profile/permission change in both deployments. Two additional compiled EN/ES/lightdark/reduced-motion/360px/native-and-standalone keyboard cases force Lit CSP fallback, inspect actual style nonces and unchanged SQLite counts. Distinct cases; not73executions in one local whole campaign."
+ },
+ "catalog": {
+ "wholeCampaign": 26,
+ "newKeyboardCase": "Both languages: validation association, field focus, review/back and retained values."
+ },
+ "package": {
+ "wheelSHA256": "ae92de7841edf0d10dd2448fa6edc2594293a999b77c6f7884acaeb4cb0a7a8c",
+ "backendInventorySHA256": "ce4601f050a56a4bf4e22c5ac6e255fb1c7a9954c7a33d6bc3f0c261834d60b5",
+ "frontendInventorySHA256": "0d5bea106d93584bcf69350be7ea97e0a147eb7787c364cc2e8ca6a23c2ae443",
+ "compiledUI": {
+ ".vite/manifest.json": "3e75c5d46ea62c74e675972b07e138ece1fedf60e3ff390b9e3dfc09ecb439cd",
+ "assets/index-BpZvnMgV.js": "97ae3298f6baf5240da7cfabb96ee373370155285dd181281f6c26516ebdc895",
+ "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48",
+ "index.html": "d0e16a3cb9640fcf1f78969fbb1211d52e2017a8eac65bf2654d04adf77e0b8a"
+ },
+ "all151PythonModulesAnd4UIResourcesVerified": true
+ },
+ "scope": "Local proof of these explicit cases, not complete HADM72/HADI8/HADW82/24browser acceptance or human review. New current-head hosted gates pending.",
+ "localEvidence": {
+ "root": "/private/tmp/domus-admin-accessibility-e7d090b",
+ "privateLocalOnly": true
+ }
}
}
diff --git a/docs/evidence/household-administration.json b/docs/evidence/household-administration.json
index a7b3e8a..328999f 100644
--- a/docs/evidence/household-administration.json
+++ b/docs/evidence/household-administration.json
@@ -612,15 +612,15 @@
}
},
"currentSourcePackage": {
- "sourceBase": "86e120ac3254a7b4b91d3d4b817f5234a0916f51",
- "sourceIdentity": "Current151-module backend is byte-identical to published86. The31-file frontend includes localized administration forms, closed document intents, application controls and current-owner draft/recovery handling. Complete acceptance and owner Edge.5 adoption remain pending.",
+ "sourceBase": "e7d090ba6bc4a0968b6813eef80d78575bcb424a",
+ "sourceIdentity": "Current151-module backend and all other frontend runtime files match reviewed e7. The administration renderer associates validation errors with controls and restores initial/approval/back-to-edit focus while preserving drafts.",
"actualProductFiles": {
- ".vite/manifest.json": "97e3e0600974d5ea6eb617be7eed3c377d0fadba49295935c17371aeaabf677e",
- "assets/index-BsSG5cPr.js": "8d94d5b5085ae82f814eb174ea202356742ae1902cf54f076881c11b93b95058",
+ ".vite/manifest.json": "3e75c5d46ea62c74e675972b07e138ece1fedf60e3ff390b9e3dfc09ecb439cd",
+ "assets/index-BpZvnMgV.js": "97ae3298f6baf5240da7cfabb96ee373370155285dd181281f6c26516ebdc895",
"assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48",
- "index.html": "03463192d0e7fb63316fcb577471de5b3eaddf3f667623f35b2cd8f36d1a55f1"
+ "index.html": "d0e16a3cb9640fcf1f78969fbb1211d52e2017a8eac65bf2654d04adf77e0b8a"
},
- "wheelSHA256": "3c78a393ebb39664a2a5b73a41b2ddbc1a63d2205a42101d276e086d20cd5695",
+ "wheelSHA256": "ae92de7841edf0d10dd2448fa6edc2594293a999b77c6f7884acaeb4cb0a7a8c",
"originalFailure": {
"run": 41505801643,
"aarch64Job": 125484707823,
@@ -629,7 +629,7 @@
},
"testsFirst": "The actual aarch64 image exposes the stale historical expectation. A new early wheel/build/declaration assertion first fails with missing currentSourcePackage before this explicit candidate inventory is added.",
"correction": "Current packaging and image tests use this declaration. The packaging gate compares it against the independently built source bundle and exact wheel bytes; the image fixture compares all installed and HTTP-served resources. Historical entry/Edge.5 evidence remains byte-identical.",
- "acceptance": "Actual wheel byte-verifies all151Pythonmodules and4compiledUIresources. Current1223Chromium unit executions have100% all4metrics over31authoredfiles (29nonempty/twotype-only). Current71compiled-browser/PythonSQLite and25built catalog cases pass. Owning-case audit, current-head hostedCPU/native review, humanUX and ownerHA remain pending."
+ "acceptance": "Actual wheel byte-verifies151Pythonmodules and4UIresources. Local1226Chromium executions have100% all4metrics across31authoredruntimefiles plus unchanged staticCSS, with independent runtime inventory audit. Local71combined compiled Python/SQLite cases,2additional EN/ES/mobile/theme/CSP keyboard cases and26built catalog cases pass. Full owning-case/human UX/owner adoption and new current-head CI/CPU/native review remain required."
},
"controllerIncrement": {
"sourceBase": "53570f20bd8c89cd2940b5b8d5187eb8ed962428",
diff --git a/docs/household-administration-operations.md b/docs/household-administration-operations.md
index 7619b77..935f589 100644
--- a/docs/household-administration-operations.md
+++ b/docs/household-administration-operations.md
@@ -21,6 +21,8 @@ confirmed absent, denied or unavailable; an unavailable name or avatar is never
Choose an available edit, adjust values, select **Review changes**, and inspect the confirmation
before **Save changes**. Participant edits identify the participant and stable membership reference.
+Validation messages describe the form controls and return keyboard focus to a relevant field or
+required approval. **Back to editing** restores the initial control and retains entered values.
Household settings, access policy, member configuration, public alias and private preferences each
keep their own original revision. An unrelated edit does not replace that revision.
diff --git a/frontend/src/presentation/domus-administration.ts b/frontend/src/presentation/domus-administration.ts
index 40ab4a3..6712cec 100644
--- a/frontend/src/presentation/domus-administration.ts
+++ b/frontend/src/presentation/domus-administration.ts
@@ -13,7 +13,7 @@ import {styles} from './styles';
export type AdministrationIntent = Readonly<{type:'submit';command:AdministrationCommand}>
| Readonly<{type:'recover'|'dismiss';key:string}> | Readonly<{type:'refresh'}>;
-type Draft = {key:string;owner:string;action:AdministrationAction;memberId?:string;payload:Record;
+type Draft = {key:string;owner:string;action:AdministrationAction;memberId?:string;payload:Record;first:string;
clearAlias:boolean;editRole:MemberRole;activationRequired:boolean;approved:boolean;
review:AdministrationCommand|null;error:string|null;sent:boolean};
type Option = Readonly<{value:string;label:string;disabled?:boolean}>;
@@ -117,7 +117,7 @@ export class DomusAdministration extends LitElement {
}
}
const key=action+':'+(memberId??'own');
- const draft:Draft={key,owner,action,...memberId===undefined?{}:{memberId},payload,clearAlias,editRole:'adult',activationRequired,
+ const draft:Draft={key,owner,action,...memberId===undefined?{}:{memberId},payload,first,clearAlias,editRole:'adult',activationRequired,
approved:false,review:null,error:null,sent:false};
if(!this.enabled(draft))return;
this.drafts.set(key,draft);this.focusAfter=this.fieldId(draft,first);this.requestUpdate();
@@ -142,7 +142,7 @@ export class DomusAdministration extends LitElement {
draft.review=administrationCommand({action:draft.action,payload,...draft.memberId===undefined?{}:{memberId:draft.memberId}});
draft.error=null;
} catch {draft.error='invalid';}
- this.focusAfter=this.fieldId(draft,draft.error===null?'review':'error');this.requestUpdate();
+ this.focusAfter=this.fieldId(draft,draft.error===null?'review':draft.error==='activationRequired'?'approved':draft.first);this.requestUpdate();
}
private save(draft:Draft):void {
if(!this.current(draft)||!this.enabled(draft)||draft.sent||draft.review===null)return;
@@ -159,12 +159,12 @@ export class DomusAdministration extends LitElement {
const id=this.fieldId(draft,key);
return html`this.change(draft,key,(event.target as HTMLInputElement).value)}>
`;
+ aria-invalid=${draft.error===null?'false':'true'} aria-describedby=${draft.error===null?nothing:this.fieldId(draft,'error')} @input=${(event:InputEvent)=>this.change(draft,key,(event.target as HTMLInputElement).value)}>`;
}
private select(draft:Draft,key:string,label:string,options:readonly Option[],disabled=false) {
const id=this.fieldId(draft,key);
return html``;
@@ -172,6 +172,7 @@ export class DomusAdministration extends LitElement {
private checkbox(draft:Draft,key:'clearAlias'|'approved'|'routine_participation_summaries',label:string) {
const id=this.fieldId(draft,key==='clearAlias'?'clear':key),value=key==='routine_participation_summaries'?draft.payload[key]===true:draft[key];
return html`{
const checked=(event.target as HTMLInputElement).checked;
if(key==='routine_participation_summaries')this.change(draft,key,checked);else this.check(draft,key,checked);
@@ -193,7 +194,7 @@ export class DomusAdministration extends LitElement {
const rule=rules.find(value=>value.permission===permission&&(!profile||(value as ProfileDecision).role===draft.editRole));
const id=this.fieldId(draft,'rule-'+permission);
return html`