2026-11-02 18:34:44 -08:00
import { AgentPushError , agentPushFailure , agentPushHttpFailure , type AgentPushStage } from "./agent-push-errors.ts" ;
2026-10-14 22:03:21 -07:00
/** Browser-only push contracts. Secrets stay in page request headers, never worker messages. */
export const AGENT_PUSH_STORAGE_KEY = "sybil.agent.push.v1" ;
export const AGENT_PUSH_CHANGED_EVENT = "sybil-agent-push-changed" ;
export const agentPushCursor = ( value : unknown ) : value is string => typeof value === "string" && /^\d{1,16}$/ . test ( value ) && Number . isSafeInteger ( Number ( value ));
export const agentPushUuid = ( value : unknown ) : value is string => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i . test ( value );
export const agentPushSourceId = agentPushUuid ;
const object = ( value : unknown ) : Record < string , unknown > | undefined => value !== null && typeof value === "object" && ! Array . isArray ( value ) ? value as Record < string , unknown > : undefined ;
export type AgentPushConfig = {
version : 1 ; enabled : boolean ; webPushAvailable : boolean ; apnsAvailable : boolean ;
sourceId : string | null ; quarantined : boolean ; vapidPublicKey? : string ;
policy : { presenceHeartbeatMs : number ; presenceLeaseMs : number };
};
export function parseAgentPushConfig ( value : unknown ) : AgentPushConfig {
const config = object ( value ), policy = object ( config ? . policy );
if ( ! config || config . version !== 1 || typeof config . enabled !== "boolean" || typeof config . webPushAvailable !== "boolean"
|| typeof config . apnsAvailable !== "boolean" || ( config . sourceId !== null && ! agentPushSourceId ( config . sourceId ))
|| ( config . quarantined !== undefined && typeof config . quarantined !== "boolean" ) || ! policy
|| typeof policy . presenceHeartbeatMs !== "number" || policy . presenceHeartbeatMs < 5000 || policy . presenceHeartbeatMs > 30000
|| typeof policy . presenceLeaseMs !== "number" || policy . presenceLeaseMs < 30000 || policy . presenceLeaseMs > 120000
2026-11-02 18:34:44 -08:00
|| ( config . webPushAvailable && config . vapidPublicKey === undefined )
2026-10-14 22:03:21 -07:00
|| ( config . vapidPublicKey !== undefined && ( typeof config . vapidPublicKey !== "string" || ! /^[A-Za-z0-9_-]{87}$/ . test ( config . vapidPublicKey )))) {
2026-11-02 18:34:44 -08:00
throw new AgentPushError ( "The server returned invalid notification configuration. Refresh Sybil; if it continues, ask the server administrator to check Web Push configuration." );
2026-10-14 22:03:21 -07:00
}
return { ... config , quarantined : config.quarantined === true } as AgentPushConfig ;
}
export type AgentPresence = { sequence : number ; visibility : "visible" | "hidden" ; focused : boolean ; interacted : boolean };
/** Nonsecret identity only; the bearer is never retained with the digest. */
export async function agentPushAuthorityScope ( baseUrl : string , token : string | null , origin = globalThis . location ? . href ) : Promise < string | null > {
if ( ! token || ! globalThis . crypto ? . subtle ) return null ;
const base = new URL ( baseUrl . replace ( /\/+$/ , "" ), origin ). href ;
const digest = await crypto . subtle . digest ( "SHA-256" , new TextEncoder (). encode ( JSON . stringify ([ base , token ])));
return Array . from ( new Uint8Array ( digest ), byte => byte . toString ( 16 ). padStart ( 2 , "0" )). join ( "" );
}
export class AgentPushClient {
private options : { baseUrl : string ; token : () => string | null ; fetch? : typeof globalThis . fetch };
constructor ( options : { baseUrl : string ; token : () => string | null ; fetch? : typeof globalThis . fetch }) { this . options = options ; }
private async request ( path : string , method = "GET" , body? : unknown , keepalive = false , expectedScope? : string ) : Promise < unknown > {
const token = this . options . token ();
2026-11-02 18:34:44 -08:00
if ( ! token ) throw new AgentPushError ( "Sign in to manage notifications." );
2026-10-14 22:03:21 -07:00
const controller = new AbortController (), timeout = setTimeout (() => controller . abort (), 8000 );
try {
// Capture the bearer before hashing, then use that exact bearer for the
// request. A concurrent sign-in must not turn old-authority cleanup into
// a new-authority DELETE whose204 falsely clears a durable tombstone.
2026-11-02 18:34:44 -08:00
if ( expectedScope !== undefined && expectedScope !== await agentPushAuthorityScope ( this . options . baseUrl , token )) throw new AgentPushError ( "The signed-in server changed. Refresh and enable notifications again." );
2026-10-14 22:03:21 -07:00
const response = await ( this . options . fetch || globalThis . fetch )( ` ${ this . options . baseUrl . replace ( /\/+$/ , "" ) } /v1/agent ${ path } ` , {
method , headers : { Authorization : `Bearer ${ token } ` , ...( body === undefined ? {} : { "Content-Type" : "application/json" }) },
...( body === undefined ? {} : { body : JSON.stringify ( body ) }), cache : "no-store" , redirect : "error" , credentials : "omit" , signal : controller.signal , keepalive ,
});
// Never echo server/provider bodies: they may contain endpoint or key material.
2026-11-02 18:34:44 -08:00
if ( ! response . ok ) throw agentPushHttpFailure ( response . status );
2026-10-14 22:03:21 -07:00
return response . status === 204 ? undefined : await response . json ();
2026-11-02 18:34:44 -08:00
} catch ( cause ) { throw agentPushFailure ( cause , "service" ); }
2026-10-14 22:03:21 -07:00
finally { clearTimeout ( timeout ); }
}
async config() { return parseAgentPushConfig ( await this . request ( "/push/config" )); }
async register ( id : string , subscription : PushSubscriptionJSON , expectedScope? : string ) {
if ( ! agentPushUuid ( id )) throw new Error ( "Invalid notification installation." );
const value = object ( await this . request ( `/push/installations/ ${ id } ` , "PUT" , { transport : "web" , enabled : true , subscription }, false , expectedScope ));
2026-11-02 18:34:44 -08:00
if ( ! value || value . installationId !== id || value . transport !== "web" || value . enabled !== true || ! Number . isSafeInteger ( value . version ) || Number ( value . version ) < 1 ) throw new AgentPushError ( "The Sybil server did not acknowledge the notification registration. Refresh and try again." );
2026-10-14 22:03:21 -07:00
}
async revoke ( id : string , expectedScope? : string ) {
if ( ! agentPushUuid ( id )) throw new Error ( "Invalid notification installation." );
await this . request ( `/push/installations/ ${ id } ` , "DELETE" , undefined , false , expectedScope );
}
async presence ( id : string , presence : AgentPresence , keepalive = false ) {
if ( ! agentPushUuid ( id )) throw new Error ( "Invalid attention session." );
await this . request ( `/presence/ ${ id } ` , "PUT" , presence , keepalive );
}
async retirePresence ( id : string ) { if ( agentPushUuid ( id )) await this . request ( `/presence/ ${ id } ` , "DELETE" , undefined , true ); }
async readState() {
const value = object ( await this . request ( "/read-state" ));
if ( agentPushSourceId ( value ? . sourceId ) && agentPushCursor ( value ? . cursor )) return { sourceId : value.sourceId , cursor : BigInt ( value . cursor ). toString () };
return null ;
}
}
export const MAX_AGENT_PUSH_REVOCATIONS = 16 ;
export type AgentPushRevocation = { installationId : string ; scope : string };
export type AgentPushConsent = { version : 1 ; installationId : string ; revision : string ; scope? : string ; sourceId? : string ; enabled : boolean ; revocationPending : boolean ; pendingRevocations? : AgentPushRevocation [] };
export function parseAgentPushConsent ( raw : string | null ) : AgentPushConsent | null {
try {
const value = object ( JSON . parse ( raw || "null" ));
if ( ! ( value ? . version === 1 && agentPushUuid ( value . installationId ) && agentPushUuid ( value . revision )
&& ( value . scope === undefined || ( typeof value . scope === "string" && /^[a-f0-9]{64}$/ . test ( value . scope )))
&& ( value . pendingRevocations === undefined || ( Array . isArray ( value . pendingRevocations ) && value . pendingRevocations . length <= MAX_AGENT_PUSH_REVOCATIONS
&& value . pendingRevocations . every ( item => agentPushUuid ( object ( item ) ? . installationId ) && typeof object ( item ) ? . scope === "string" && /^[a-f0-9]{64}$/ . test ( String ( object ( item ) ? . scope )))))
&& typeof value . enabled === "boolean" && typeof value . revocationPending === "boolean" )) return null ;
// Keep the installation identity for cleanup when migrating old/unbound
// consent. An absent or malformed source can never authorize registration.
return { ... value , sourceId : agentPushSourceId ( value . sourceId ) ? value.sourceId : undefined } as AgentPushConsent ;
} catch { return null ; }
}
export type AgentPushView = { busy : boolean ; enabled : boolean ; canEnable : boolean ; message : string ; error : string | null };
export type PushSubscriptionHandle = Pick < PushSubscription , "toJSON" | "unsubscribe" >;
type PushPlatform = {
2026-11-02 18:34:44 -08:00
support : () => "supported" | "unsupported" | "install" | "insecure" ;
2026-10-14 22:03:21 -07:00
permission : () => NotificationPermission ;
requestPermission : () => Promise < NotificationPermission >;
subscription : () => Promise < PushSubscriptionHandle | null >;
subscribe : ( publicKey : string ) => Promise < PushSubscriptionHandle >;
clearNotifications : () => Promise < void >;
};
/** Explicit consent survives restarts; a revoke tombstone always wins over an older enable. */
export function createAgentPushController ( options : {
api : Pick < AgentPushClient , "config" | "register" | "revoke" >;
platform : PushPlatform ; read : () => AgentPushConsent | null ; write : ( consent : AgentPushConsent ) => void ; remove ?: () => void ;
scope : () => Promise < string | null >;
uuid : () => string ; lock : < T >( operation : () => Promise < T >) => Promise < T >; changed : ( view : AgentPushView ) => void ;
}) {
let config : AgentPushConfig | null = null , busy = false , error : string | null = null , localRevocation : AgentPushConsent | null = null ;
2026-11-02 18:34:44 -08:00
let refreshError : string | null = null ;
2026-10-14 22:03:21 -07:00
let scope : string | null = null ;
let registeredSubscription : string | null = null ;
const currentConsent = () => localRevocation || options . read ();
const matchesContext = ( record : AgentPushConsent ) => !! scope && record . scope === scope
&& agentPushSourceId ( record . sourceId ) && record . sourceId === config ? . sourceId ;
2026-11-02 18:34:44 -08:00
const transportAvailable = () => !! config ? . enabled && config . webPushAvailable && !! config . vapidPublicKey && ! config . quarantined && agentPushSourceId ( config . sourceId );
2026-10-14 22:03:21 -07:00
const refreshContext = async () => {
const nextScope = await options . scope (), nextConfig = await options . api . config ();
2026-11-02 18:34:44 -08:00
if ( nextScope !== await options . scope ()) { scope = null ; config = null ; throw new AgentPushError ( "The signed-in server changed. Refresh and enable notifications again." ); }
2026-10-14 22:03:21 -07:00
scope = nextScope ; config = nextConfig ;
};
const recheckContext = async ( record : AgentPushConsent ) => {
await refreshContext ();
return matchesContext ( record ) && transportAvailable ();
};
const state = () : AgentPushView => {
const consent = currentConsent (), support = options . platform . support (), permission = options . platform . permission ();
const available = !! scope && transportAvailable ();
const cleanupFull = ( consent ? . pendingRevocations ? . length || 0 ) >= MAX_AGENT_PUSH_REVOCATIONS ;
const enabled = consent ? . enabled === true && ! consent . revocationPending && permission === "granted" ;
const message = support === "install" ? "On iPhone or iPad, add Sybil to your Home Screen, then enable notifications there."
2026-11-02 18:34:44 -08:00
: support === "insecure" ? "Notifications require a secure connection. Open Sybil over HTTPS, then try again."
2026-10-14 22:03:21 -07:00
: support === "unsupported" ? "This browser does not support notifications here. Use a supported browser over HTTPS."
2026-11-02 18:34:44 -08:00
: permission === "denied" ? "Notifications are blocked. Allow Sybil in browser site settings and allow the browser's notifications in system settings, then refresh."
2026-10-14 22:03:21 -07:00
: cleanupFull ? "Notification cleanup is full. Sign in to a previous server to finish removing old registrations before enabling more."
: consent ? . revocationPending ? "Notifications are off here. Server removal will retry when connected."
2026-11-02 18:34:44 -08:00
: config ? . quarantined ? "Notification delivery is paused after an Agent source change. Ask the server administrator to reconcile the source before enabling notifications."
: config ? . enabled && config . webPushAvailable && ! agentPushSourceId ( config . sourceId ) ? "The Agent notification source is not ready yet. Refresh in a moment; if it continues, ask the server administrator to check the Agent connection."
2026-10-14 22:03:21 -07:00
: ! available ? "Notifications are not configured on this server yet."
2026-10-14 23:36:38 -07:00
: enabled ? "Notifications are on for this browser."
2026-10-14 22:03:21 -07:00
: "Get an alert for unread Agent replies when all your web sessions are inactive." ;
2026-11-02 18:34:44 -08:00
return { busy , enabled , canEnable : ! busy && available && support === "supported" && permission !== "denied" && ! consent ? . revocationPending && ! cleanupFull , message , error : error || refreshError };
2026-10-14 22:03:21 -07:00
};
const publish = () => options . changed ( state ());
const write = ( consent : AgentPushConsent ) => { options . write ( consent ); publish (); };
const consent = () : AgentPushConsent => currentConsent () || { version : 1 , installationId : options.uuid (), revision : options.uuid (), ...( scope ? { scope } : {}), enabled : false , revocationPending : false };
const retireAtOriginalAuthority = async ( record : Pick < AgentPushConsent , "installationId" | "scope" >) => {
if ( ! record . scope || record . scope !== await options . scope ()) return false ;
await options . api . revoke ( record . installationId , record . scope );
return true ;
};
const retryRevocations = async () => {
const original = currentConsent ();
if ( ! original ) return ;
for ( const pending of original . pendingRevocations || []) {
if ( currentConsent () ? . revision !== original . revision ) return ;
const removed = await retireAtOriginalAuthority ( pending ). catch (() => false );
const latest = currentConsent ();
if ( removed && latest ? . revision === original . revision ) {
write ({ ... latest , pendingRevocations : ( latest . pendingRevocations || []). filter ( item => item . installationId !== pending . installationId || item . scope !== pending . scope ) });
}
}
};
const revoke = async ( record : AgentPushConsent ) => {
let serverRemoved = false , subscriptionRemoved = false ;
await Promise . all ([
retireAtOriginalAuthority ( record ). then ( removed => { serverRemoved = removed ; }). catch (() => undefined ),
options . platform . subscription (). then ( async subscription => { subscriptionRemoved = ! subscription || await subscription . unsubscribe (); }). catch (() => undefined ),
options . platform . clearNotifications (). catch (() => undefined ),
]);
const authority = await options . scope (). catch (() => null );
const latest = currentConsent ();
if ( latest ? . revision === record . revision && subscriptionRemoved ) {
const queue = latest . pendingRevocations || [];
const archived = !! record . scope && queue . some ( item => item . installationId === record . installationId && item . scope === record . scope );
const differentAuthority = record . scope && record . scope !== authority ;
if ( serverRemoved || archived ) {
const removed = { ... latest , revocationPending : false };
try { write ( removed ); localRevocation = null ; } catch { localRevocation = removed ; }
} else if ( differentAuthority && queue . length < MAX_AGENT_PUSH_REVOCATIONS ) {
// Keep only the old nonsecret authority digest and ID. A current-server
// DELETE/204 is never evidence that a different server retired this ID.
const archivedRecord = { ... latest , revocationPending : false ,
pendingRevocations : [... queue , { installationId : record.installationId , scope : record.scope ! }] };
try { write ( archivedRecord ); localRevocation = null ; } catch { localRevocation = latest ; }
}
}
};
const invalidate = async ( record : AgentPushConsent ) => {
if ( currentConsent () ? . revision !== record . revision ) {
await retireAtOriginalAuthority ( record ). catch (() => undefined );
return ;
}
const pending = { ... record , enabled : false , revocationPending : true , revision : options.uuid () };
registeredSubscription = null ;
try { write ( pending ); } catch { localRevocation = pending ; }
await revoke ( pending );
};
const disable = async () => {
2026-11-02 18:34:44 -08:00
error = null ; refreshError = null ;
2026-10-14 22:03:21 -07:00
const record = { ... consent (), enabled : false , revocationPending : true , revision : options.uuid () };
// Synchronous durable intent is necessary before logout clears page credentials.
registeredSubscription = null ;
try { write ( record ); }
catch {
localRevocation = record ;
// Some browsers reject setItem when full but still permit removal.
try { options . remove ? .(); } catch { /* Unsubscribe/revoke below still run. */ }
error = "Browser storage is unavailable. Check that notifications are off in browser settings." ;
}
busy = true ; publish ();
try { await options . lock (() => revoke ( record )); }
catch {
// A stuck tab must not prevent logout cleanup. The server permanently
// retires this UUID, so cleanup remains safe without acquiring that lock.
await revoke ( record ). catch (() => undefined );
error = "Notifications are off here. Removal will retry when connected." ;
}
finally { busy = false ; publish (); }
};
2026-11-02 18:34:44 -08:00
const refresh = async ( clearError = false ) => {
2026-10-14 22:03:21 -07:00
if ( busy ) return ;
2026-11-02 18:34:44 -08:00
if ( clearError ) error = null ;
2026-10-14 22:03:21 -07:00
try {
await refreshContext ();
await options . lock ( async () => {
await retryRevocations ();
let record = currentConsent ();
if ( ! record ) return ;
if ( record . revocationPending ) { await revoke ( record ); return ; }
if ( ! record . enabled ) return ;
if ( ! matchesContext ( record )) { await invalidate ( record ); return ; }
const subscription = await options . platform . subscription ();
if ( currentConsent () ? . revision !== record . revision ) return ;
if ( options . platform . permission () !== "granted" || ! subscription ) {
record = { ... record , enabled : false , revocationPending : true , revision : options.uuid () }; write ( record ); await revoke ( record ); return ;
}
if ( transportAvailable ()) {
const serialized = JSON . stringify ( subscription . toJSON ());
if ( registeredSubscription !== serialized ) {
const matching = await recheckContext ( record );
if ( currentConsent () ? . revision !== record . revision ) return ;
if ( ! matching ) { await invalidate ( record ); return ; }
await options . api . register ( record . installationId , subscription . toJSON (), record . scope );
if ( currentConsent () ? . revision !== record . revision ) { await retireAtOriginalAuthority ( record ); return ; }
// A source replacement may complete while the PUT is in flight.
// Never retain that registration without a fresh source check.
try {
const stillMatching = await recheckContext ( record );
if ( currentConsent () ? . revision !== record . revision ) { await retireAtOriginalAuthority ( record ); return ; }
if ( ! stillMatching ) { await invalidate ( record ); return ; }
2026-11-02 18:34:44 -08:00
} catch ( cause ) { await invalidate ( record ); throw cause ; }
2026-10-14 22:03:21 -07:00
registeredSubscription = serialized ;
}
}
});
2026-11-02 18:34:44 -08:00
refreshError = null ;
} catch ( cause ) { refreshError = agentPushFailure ( cause , "service" ). message ; }
2026-10-14 22:03:21 -07:00
publish ();
};
const enable = async () => {
if ( ! state (). canEnable || state (). enabled || busy || ! config ? . vapidPublicKey || ! scope || ! agentPushSourceId ( config . sourceId )) return ;
2026-11-02 18:34:44 -08:00
error = null ; refreshError = null ;
2026-10-14 22:03:21 -07:00
// Explicit revocation permanently retires the old UUID on the server. Each
// new opt-in has a new identity, so a delayed old PUT cannot resurrect it.
const record : AgentPushConsent = { version : 1 , installationId : options.uuid (), revision : options.uuid (), scope , sourceId : config.sourceId ,
enabled : false , revocationPending : false , pendingRevocations : currentConsent () ? . pendingRevocations || [] };
const publicKey = config . vapidPublicKey ;
try { options . write ( record ); localRevocation = null ; } catch { error = "Allow browser storage before enabling notifications." ; publish (); return ; }
// Must execute in the original click stack, before locks, worker startup or fetches.
let permission : Promise < NotificationPermission >;
try { permission = options . platform . requestPermission (); }
2026-11-02 18:34:44 -08:00
catch ( cause ) { error = agentPushFailure ( cause , "permission" ). message ; publish (); return ; }
2026-10-14 22:03:21 -07:00
busy = true ; publish ();
2026-11-02 18:34:44 -08:00
let stage : AgentPushStage = "permission" ;
2026-10-14 22:03:21 -07:00
try {
2026-11-02 18:34:44 -08:00
const decision = await permission ;
if ( decision !== "granted" ) { error = decision === "denied"
? "Notifications were not allowed. Allow Sybil in browser site settings and allow the browser's notifications in system settings, then refresh."
: "The notification prompt was dismissed. Tap Enable notifications again and choose Allow." ; return ; }
stage = "lock" ;
2026-10-14 22:03:21 -07:00
await options . lock ( async () => {
2026-11-02 18:34:44 -08:00
stage = "service" ;
2026-10-14 22:03:21 -07:00
const unchanged = () => options . read () ? . revision === record . revision ;
if ( ! unchanged ()) return ;
const matching = await recheckContext ( record );
if ( ! unchanged ()) return ;
if ( ! matching || config ? . vapidPublicKey !== publicKey ) { await invalidate ( record ); return ; }
2026-11-02 18:34:44 -08:00
stage = "subscription" ;
2026-10-14 22:03:21 -07:00
const subscription = await options . platform . subscription () || await options . platform . subscribe ( publicKey );
if ( ! unchanged ()) { await subscription . unsubscribe (); return ; }
// A tombstone also covers a lost registration response or failed local storage write.
2026-11-02 18:34:44 -08:00
const pending = { ... record , enabled : false , revocationPending : true };
2026-10-14 22:03:21 -07:00
try {
2026-11-02 18:34:44 -08:00
stage = "storage" ; write ( pending );
stage = "service" ;
2026-10-14 22:03:21 -07:00
const matchingBeforeRegister = await recheckContext ( record );
if ( ! unchanged () || ! matchingBeforeRegister || config ? . vapidPublicKey !== publicKey ) { await revoke ( pending ); return ; }
await options . api . register ( record . installationId , subscription . toJSON (), record . scope );
if ( ! unchanged ()) { await retireAtOriginalAuthority ( record ); await subscription . unsubscribe (); return ; }
const matchingAfterRegister = await recheckContext ( record );
if ( ! unchanged () || ! matchingAfterRegister || config ? . vapidPublicKey !== publicKey ) { await revoke ( pending ); return ; }
2026-11-02 18:34:44 -08:00
stage = "storage" ; write ({ ... record , enabled : true , revocationPending : false });
2026-10-14 22:03:21 -07:00
registeredSubscription = JSON . stringify ( subscription . toJSON ());
2026-11-02 18:34:44 -08:00
} catch ( cause ) {
// Retain cleanup intent even when storage fails after subscribing.
if ( options . read () ? . revision === record . revision ) localRevocation = pending ;
2026-10-14 22:03:21 -07:00
await revoke ( pending );
2026-11-02 18:34:44 -08:00
throw agentPushFailure ( cause , stage );
2026-10-14 22:03:21 -07:00
}
});
2026-11-02 18:34:44 -08:00
} catch ( cause ) { error = agentPushFailure ( cause , stage ). message ; }
2026-10-14 22:03:21 -07:00
finally { busy = false ; publish (); }
};
return { state , refresh , enable , disable };
}
/** Independent ordered lease per document, including focused-but-idle and recent input. */
export function createAgentPresenceReporter ( options : {
snapshot : () => { visibility : "visible" | "hidden" ; focused : boolean };
send : ( presence : AgentPresence , keepalive : boolean ) => Promise < void >;
}) {
let sequence = 0 , interaction = 0 , acknowledgedInteraction = 0 ;
return {
interact() { interaction ++ ; },
async report ( keepalive = false , hidden = false ) {
const snapshot = hidden ? { visibility : "hidden" as const , focused : false } : options . snapshot ();
const capturedInteraction = interaction ;
const presence = { ... snapshot , focused : snapshot.visibility === "visible" && snapshot . focused , sequence : ++ sequence , interacted : capturedInteraction > acknowledgedInteraction };
await options . send ( presence , keepalive );
acknowledgedInteraction = Math . max ( acknowledgedInteraction , capturedInteraction );
},
};
}