Files
suaveplan/packages/api-stack/api/api-ws-bun/src/principal/principal-expiry.ts
2026-05-27 23:29:53 +01:00

65 lines
2.2 KiB
TypeScript

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;
}
}