gstack/browse/src/pty-session-lease.ts

138 lines
4.9 KiB
TypeScript

/**
* PTY session lease registry (v1.44+).
*
* Separates two concerns that pre-v1.44 were conflated under one token:
*
* - **sessionId** — stable, non-secret identifier for a single PTY session.
* Safe to log, safe to include in URLs and server access logs, safe to
* keep in DevTools. Identifies "this terminal," not "you're allowed to
* use this terminal."
*
* - **attachToken** — secret, short-lived (30 s) bearer credential that
* grants the WS upgrade for ONE attach attempt against a session. Minted
* on every /pty-session and /pty-session/reattach call; revoked when
* the WS upgrade consumes it. Kept out of logs.
*
* - **lease** — server-side bookkeeping that maps sessionId → expiresAt.
* Re-attach within the lease window resumes the same PTY (and replays
* the ring buffer from terminal-agent). Lease expiry tears down the
* session.
*
* Codex outside-voice (T1 of the eng review) pushed for this separation:
* "the auth token IS the session id" collapsed identity into a secret,
* meaning re-attach URLs and logs carry the bearer credential. The lease
* model fixes that without changing the user experience.
*
* Mint cadence:
* - Initial /pty-session: mint sessionId + lease + attachToken (one round trip).
* - /pty-session/reattach: validate sessionId/lease, mint fresh attachToken.
* - /pty-restart: revoke old lease, mint fresh sessionId + lease + attachToken.
* - /pty-dispose: revoke lease (and the terminal-agent disposes the PTY).
*
* Lease TTL is env-overridable so v1.44 e2e tests can compress detach
* windows to 1 s instead of waiting 30 minutes per assertion.
*/
import * as crypto from 'crypto';
interface Lease {
createdAt: number;
expiresAt: number;
}
const LEASE_TTL_MS = parseInt(
process.env.GSTACK_PTY_LEASE_TTL_MS || `${30 * 60 * 1000}`,
10,
); // 30 minutes default; covers idle-but-engaged user sessions
const MAX_LEASES = 10_000;
const leases = new Map<string, Lease>();
/**
* Mint a fresh sessionId + lease. Returns the non-secret sessionId and
* the expiry timestamp (caller surfaces both to the client). Never throws.
*/
export function mintLease(): { sessionId: string; expiresAt: number } {
const sessionId = crypto.randomBytes(32).toString('base64url');
const now = Date.now();
const expiresAt = now + LEASE_TTL_MS;
leases.set(sessionId, { createdAt: now, expiresAt });
pruneExpired(now);
return { sessionId, expiresAt };
}
/**
* Check whether a lease is still valid (exists AND not expired). Returns
* the current expiresAt for valid leases; null otherwise. Lazily prunes
* stale entries.
*/
export function validateLease(sessionId: string | null | undefined): { ok: true; expiresAt: number } | { ok: false } {
if (!sessionId) return { ok: false };
const lease = leases.get(sessionId);
if (!lease) {
pruneExpired(Date.now());
return { ok: false };
}
if (Date.now() > lease.expiresAt) {
leases.delete(sessionId);
pruneExpired(Date.now());
return { ok: false };
}
return { ok: true, expiresAt: lease.expiresAt };
}
/**
* Extend the lease's expiresAt to `now + LEASE_TTL_MS`. Caller should
* gate refresh on `expiresAt - now < REFRESH_THRESHOLD` (D10 lazy
* refresh: avoid refreshing on every keepalive when the lease is
* comfortably far from expiry).
*
* Returns `{ ok: true, expiresAt }` on success, `{ ok: false }` if the
* lease is unknown or already expired (the agent must close the WS and
* surface auth-invalid). Critical security invariant: never resurrect
* an expired lease — the 30-min TTL is what bounds blast radius for a
* leaked attach token whose lease should have been GC'd.
*/
export function refreshLease(sessionId: string | null | undefined): { ok: true; expiresAt: number } | { ok: false } {
if (!sessionId) return { ok: false };
const lease = leases.get(sessionId);
if (!lease) return { ok: false };
const now = Date.now();
if (now > lease.expiresAt) {
leases.delete(sessionId);
return { ok: false };
}
lease.expiresAt = now + LEASE_TTL_MS;
return { ok: true, expiresAt: lease.expiresAt };
}
/**
* Drop a lease. Called on explicit dispose (/pty-dispose, /pty-restart,
* WS close with code 4001) and on session timeout in terminal-agent.
*/
export function revokeLease(sessionId: string | null | undefined): void {
if (!sessionId) return;
leases.delete(sessionId);
}
/** Returns the lease count — test + observability helper. */
export function leaseCount(): number {
return leases.size;
}
/** Test-only reset. */
export function __resetLeases(): void {
leases.clear();
}
function pruneExpired(now: number): void {
let checked = 0;
for (const [sessionId, lease] of leases) {
if (checked++ >= 20) break;
if (lease.expiresAt <= now) leases.delete(sessionId);
}
while (leases.size > MAX_LEASES) {
const first = leases.keys().next().value;
if (!first) break;
leases.delete(first);
}
}