fix(gateway): bound talk handoff expiry

This commit is contained in:
Peter Steinberger
2026-05-30 13:41:06 -04:00
parent 6f20f29688
commit 912a276ca1
2 changed files with 55 additions and 4 deletions

View File

@@ -107,6 +107,45 @@ describe("talk handoff store", () => {
vi.useRealTimers();
});
it("expires handoffs immediately when the creation clock is invalid", () => {
clearTalkHandoffsForTest();
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
const handoff = createTalkHandoff({
sessionKey: "session:main",
ttlMs: 5000,
});
expect(handoff.createdAt).toBe(0);
expect(handoff.expiresAt).toBe(0);
expect(joinTalkHandoff(handoff.id, handoff.token)).toEqual({
ok: false,
reason: "expired",
});
} finally {
dateNow.mockRestore();
}
});
it("expires handoffs immediately when expiry would exceed Date bounds", () => {
clearTalkHandoffsForTest();
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
const handoff = createTalkHandoff({
sessionKey: "session:main",
ttlMs: 5000,
});
expect(handoff.expiresAt).toBe(0);
expect(joinTalkHandoff(handoff.id, handoff.token)).toEqual({
ok: false,
reason: "expired",
});
vi.useRealTimers();
});
it("joins and revokes handoffs with only the bearer token", () => {
clearTalkHandoffsForTest();
const handoff = createTalkHandoff({ sessionKey: "session:main" });

View File

@@ -1,4 +1,10 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import {
asDateTimestampMs,
isFutureDateTimestampMs,
resolveDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "../shared/number-coercion.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { recordTalkObservabilityEvent } from "../talk/observability.js";
import {
@@ -99,8 +105,10 @@ const handoffs = new Map<string, TalkHandoffRecord>();
export function createTalkHandoff(params: TalkHandoffCreateParams): TalkHandoffCreateResult {
pruneExpiredTalkHandoffs();
const createdAt = Date.now();
const rawCreatedAt = Date.now();
const createdAt = resolveDateTimestampMs(rawCreatedAt);
const ttlMs = normalizeTtlMs(params.ttlMs);
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawCreatedAt }) ?? 0;
const id = randomUUID();
const roomId = `talk_${id}`;
const token = randomBytes(32).toString("base64url");
@@ -127,7 +135,7 @@ export function createTalkHandoff(params: TalkHandoffCreateParams): TalkHandoffC
transport: params.transport ?? "managed-room",
brain: params.brain ?? "agent-consult",
createdAt,
expiresAt: createdAt + ttlMs,
expiresAt,
room,
};
appendTalkHandoffRoomEvent(record, {
@@ -285,8 +293,12 @@ function normalizeTtlMs(value: number | undefined): number {
}
function pruneExpiredTalkHandoffs(now = Date.now()): void {
const validNow = asDateTimestampMs(now);
if (validNow === undefined) {
return;
}
for (const [id, record] of handoffs) {
if (record.expiresAt <= now) {
if (!isFutureDateTimestampMs(record.expiresAt, { nowMs: validNow })) {
appendTalkHandoffRoomEvent(record, {
type: "session.closed",
payload: { reason: "expired", handoffId: id, roomId: record.roomId },
@@ -344,7 +356,7 @@ function resolveTalkHandoffAccess(
if (!record) {
return { ok: false, reason: "not_found" };
}
if (record.expiresAt <= Date.now()) {
if (!isFutureDateTimestampMs(record.expiresAt)) {
appendTalkHandoffRoomEvent(record, {
type: "session.closed",
payload: { reason: "expired", handoffId: id, roomId: record.roomId },