import { raisePrincipalExpired } from "../error-codes/error-codes.js"; /** Clock abstraction used by the principal-expiry watcher. */ export interface PrincipalExpiryClock { now(): number; } /** Listener signature invoked when the principal's token expires. */ export type PrincipalExpiryListener = (event: { readonly connectionId: string; readonly principalId: string; }) => void; /** Options for {@link PrincipalExpiryWatcher}. */ export interface PrincipalExpiryWatcherOptions { readonly connectionId: string; readonly principalId: string; /** * Wall-clock expiry timestamp (ms since epoch). The handshake * pipeline reads this from the resolved token; once `now()` reaches * this value the watcher fires. */ readonly expiresAt: number; readonly clock: PrincipalExpiryClock; readonly onExpired: PrincipalExpiryListener; } /** * Tracks the frozen principal's underlying token expiry. The engine * polls `check()` from a timer (production) or from a test loop driven * by `FakeClock`. Once the expiry passes, the watcher fires the * `onExpired` listener exactly once and throws * `WS_BUN.PRINCIPAL_EXPIRED` so the engine can close the connection. * * The principal itself is NOT mutated by this watcher — the contract * requires it to stay frozen for the connection's lifetime. The watcher * is the engine's signal that it is time to drop the connection. */ export class PrincipalExpiryWatcher { private fired = false; public constructor( private readonly options: PrincipalExpiryWatcherOptions ) {} /** * Run one expiry check. If `now() >= expiresAt` and the watcher has * not yet fired, invoke `onExpired` and throw `WS_BUN.PRINCIPAL_EXPIRED`. */ public check(): void { if (this.fired) return; if (this.options.clock.now() < this.options.expiresAt) return; this.fired = true; this.options.onExpired({ connectionId: this.options.connectionId, principalId: this.options.principalId, }); raisePrincipalExpired(this.options.connectionId); } public get hasFired(): boolean { return this.fired; } }