fix(server): schedule gateway credential expiry from committed changes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-24 09:54:24 -07:00
1 parent d41590ac0e
commit 75332b9641
10 files changed
+339 -56

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
# Gateway credential expiry and idle behavior
Gateway sessions and named gateway tokens already persist their expiry times.
`cleanupExpiredSessions` remains the owner of physical deletion. Authentication
continues to check expiry, revocation, company scope, run ownership, and active
run state on use. No table, authorization rule, or external alarm is added.
## Producer inventory
- `createSession` registers intent before inserting a run-bound session in a
transaction. Its existing audit and returned credential remain unchanged.
- `createNamedGatewayToken` registers intent before inserting an expiring token
in a transaction. A token without an expiry owes no expiry cleanup and does
not notify this worker.
- Last-use updates do not change expiry. Token/session revocation, including
heartbeat completion and connection removal, also leaves the saved expiry
intact. Revoked rows retain the same physical deletion deadline.
- Gateway/company deletion removes dependent rows and can leave one harmless
stale deadline. It cannot create overdue work. No supported service changes
a saved credential expiry after creation.
The database signal layer owns outer commit, rollback, savepoints, and uncertain
outcomes. The worker supports producers sharing that signal owner. Independent
SQL writers and another process need a separate live ingress contract. Startup
reconstructs existing rows but does not provide continuous replica discovery.
## Worker contract
`nextCredentialExpiryAt` reads the earliest non-null expiry across both tables.
An empty credential store or retained non-expiring tokens returns no deadline.
`registerGatewayCredentialExpiryWork` supplies cleanup and deadline selection to
the shared `registerDeadlineWork` helper. Provider and authentication behavior
stay in the gateway service; startup only registers the worker.
The worker starts where the old startup cleanup ran. With heartbeat scheduling
enabled, committed changes and actual expiry times replace the recurring cleanup
call. With heartbeat scheduling disabled, the existing startup-only cleanup is
preserved. Tool action-review recovery is a separate obligation and is unchanged.
Existing token deletion is bounded to 500 rows per pass. A remaining overdue
expiry schedules another pass after 30 seconds. Failed reconciliation also
retries at that bound. No unfinished reconciliation or future expiry means no
recurring SQL. A generation retains writes arriving during reconciliation.
Startup restores deadlines; drain retains first writes without passive SQL;
resume completes overdue cleanup. The coordinator owns shutdown and waits for
in-flight work.
## Evidence and limits
The new PostgreSQL suite exercises real session/token creation, configured idle
state, non-expiring history, expiry ordering, revoked-token cleanup, outer commit
and rollback, startup reconstruction, a backlog larger than one cleanup batch,
and drain/resume. It tests expired authentication before deferred physical
cleanup, through both session and named-gateway entry points. Existing gateway
acceptance and server-startup tests cover the changed integration boundaries.
Use only disposable migrated databases for the test URL overrides. Their names
must end in `_test`; the suites delete their fixtures. Ordinary CI uses embedded
PostgreSQL. Test credentials are synthetic and no external provider is contacted
by the expiry suite.
The migration proves worker behavior, not host suspension. Existing Cloud idle
proof remains conservative. An in-process deadline cannot wake stopped compute;
external wake coverage must be verified before changing sleep eligibility.
## Related unmerged token-lifetime work
At implementation time, PRs #17221 and #17274 change run-scoped token lifetime
and cleanup eligibility. This slice preserves the current master policy and
changes scheduling only. When combining those changes, the deadline query and
producer signals must use the same token eligibility as cleanup: run-token idle
markers must not schedule the operator-token expiry worker. Keep the run-liveness
reaper with its own owner and retain both regression suites. A conflict-free
text merge alone does not prove this integration.
@@ -649,8 +649,10 @@ Longer polling intervals alone do not complete an item.
- [ ] **JOB-21 — Secret proposals and expired action tokens (partially migrated).**
Secret-proposal expiry now uses transaction signals and saved expiry deadlines;
see [the lifecycle contract](../idle-secret-proposal-expiry.md). Tool review
recovery and gateway token cleanup still use **30s** queries. Change: enforce expiry synchronously on use and
see [the lifecycle contract](../idle-secret-proposal-expiry.md). Gateway token
and session expiry now also uses saved deadlines and producer signals; see
[its lifecycle contract](../idle-gateway-credential-expiry.md). Tool review
recovery still uses **30s** queries. Change: enforce expiry synchronously on use and
register required scrub/cleanup deadlines. **Complete when:** idle cleanup
does not extend authorization or secret-retention periods. Sources:
[secret-proposals.ts](../../server/src/services/secret-proposals.ts),
@@ -18,7 +18,7 @@ Do not mark an item done from a timer removal or a green check alone.
| 04 | Decision retention and notifications | Retention deadlines and notification delivery are separate reasons | Pending |
| 05 | Decision expiry and interrupted effects | Expiry, effect recovery, continuation recovery | Pending |
| 06 | Tool connection health | Configuration/use activation and defined proactive health deadlines | Pending |
| 07 | Secret proposals, tool reviews, gateway tokens | Separate expiry/scrub/recovery owners | Secret-proposal expiry in progress; tool reviews and gateway tokens pending |
| 07 | Secret proposals, tool reviews, gateway tokens | Separate expiry/scrub/recovery owners | Secret proposals: [#17446](https://github.com/paperclipai/paperclip/pull/17446), CI/review running; gateway credential expiry in progress; tool reviews pending |
| 08 | GitHub connection events and continuity | Event discovery/replay and credential continuity separately | Pending |
| 09 | Merged-PR confirmations | Candidate writes, webhook activation where supported, outstanding-candidate fallback | Pending |
| 10 | External-object refresh | Configuration changes, refresh deadlines, source events | Pending |
@@ -0,0 +1,164 @@
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { activityLog, agents, companies, createDb, heartbeatRuns, toolAccessAuditEvents, toolGatewaySessions,
toolMcpGateways, toolMcpGatewayTokens, toolProfiles } from "@paperclipai/db";
import { createToolGatewayService } from "../services/tool-gateway.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { registerGatewayCredentialExpiryWork } from "../services/gateway-credential-expiry-work.js";
import { idleWorkSnapshot } from "../services/task-admission.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_GATEWAY_EXPIRY_TEST_DATABASE_URL");
const supported = external ? true : (await getEmbeddedPostgresTestSupport()).supported;
describe.skipIf(!supported)("gateway credential expiry", () => {
let db: ReturnType<typeof createDb>;
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let gateway: ReturnType<typeof createToolGatewayService>;
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
let companyId: string, agentId: string, runId: string, namedId: string;
let draining: boolean;
const errors: unknown[] = [];
beforeAll(async () => {
if (!external) temporary = await startEmbeddedPostgresTestDatabase("gateway-expiry-");
db = createDb(external ?? temporary!.connectionString, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
}, 90_000);
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
beforeEach(async () => {
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select pg_sleep(0.01)`)));
companyId = randomUUID(); agentId = randomUUID(); runId = randomUUID(); draining = false; errors.length = 0;
await db.insert(companies).values({ id: companyId, name: "Credential expiry", issuePrefix: "EXP" });
await db.insert(agents).values({ id: agentId, companyId, name: "Agent", role: "engineer", adapterType: "process" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "assignment", status: "running" });
const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: "expiry", name: "Expiry", defaultAction: "deny" }).returning();
gateway = createToolGatewayService(db, { toolActionSigningSecret: "expiry-test-only" });
namedId = (await gateway.createNamedGateway({ companyId, body: { name: "Expiry", profileId: profile.id } })).id;
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: true });
});
afterEach(async () => {
await coordinator?.stop(); coordinator = undefined;
vi.useRealTimers(); vi.restoreAllMocks();
await db.delete(activityLog);
await db.delete(toolAccessAuditEvents);
await db.delete(toolGatewaySessions);
await db.delete(toolMcpGatewayTokens);
await db.delete(toolMcpGateways);
await db.delete(toolProfiles);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
expect(errors).toEqual([]);
});
async function start() {
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining,
canReconcile: () => true, onError: error => errors.push(error) });
await registerGatewayCredentialExpiryWork(coordinator, gateway, () => !draining).ready;
}
async function settle(ms = 60_000) {
await vi.advanceTimersByTimeAsync(ms);
if (!draining) await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0), { timeout: 5000 });
}
async function quiet() {
await settle(); expect(coordinator!.nextWakeAt()).toBeNull();
const reads = vi.spyOn(db, "execute"), selects = vi.spyOn(db, "select"), deletes = vi.spyOn(db, "delete");
await settle(60 * 60_000);
expect(reads).not.toHaveBeenCalled(); expect(selects).not.toHaveBeenCalled(); expect(deletes).not.toHaveBeenCalled();
reads.mockRestore(); selects.mockRestore(); deletes.mockRestore();
}
const token = (expiresAt?: Date) => gateway.createNamedGatewayToken({ companyId, gatewayId: namedId, body: { name: "Expiry", expiresAt } });
const session = (service = gateway) => service.createSession({ companyId, agentId, runId, ttlMs: 5 * 60_000 });
it("stays quiet with configuration and a nonexpiring token, then expires real tokens and sessions by deadline", async () => {
await start(); await quiet();
const retained = await token(); await quiet();
const active = await session();
const due = new Date(Date.now() + 3 * 60_000);
const expiring = await token(due);
await settle();
expect(coordinator!.nextWakeAt()).toBe(due.getTime());
const reads = vi.spyOn(db, "execute"), selects = vi.spyOn(db, "select");
await settle(30_000);
expect(reads).not.toHaveBeenCalled(); expect(selects).not.toHaveBeenCalled();
reads.mockRestore(); selects.mockRestore();
await gateway.revokeNamedGatewayToken({ companyId, tokenId: expiring.id });
await settle(2 * 60_000);
expect((await db.select().from(toolMcpGatewayTokens)).map(row => row.id)).toEqual([retained.id]);
expect(coordinator!.nextWakeAt()).toBe(active.expiresAt.getTime());
await settle(2 * 60_000);
expect(await db.select().from(toolGatewaySessions)).toEqual([]);
await quiet();
});
it.each([
{ kind: "session", rollback: false }, { kind: "session", rollback: true },
{ kind: "token", rollback: false }, { kind: "token", rollback: true },
])("respects $kind outer transaction settlement (rollback $rollback)", async ({ kind, rollback }) => {
await start(); await quiet();
let release!: () => void, written!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const staged = new Promise<void>(resolve => { written = resolve; });
const rollbackError = new Error("rollback fixture");
const writing = db.transaction(async tx => {
const scoped = createToolGatewayService(tx as unknown as typeof db, { toolActionSigningSecret: "expiry-test-only" });
if (kind === "session") await session(scoped);
else await scoped.createNamedGatewayToken({ companyId, gatewayId: namedId,
body: { name: "Outer transaction", expiresAt: new Date(Date.now() + 5 * 60_000) } });
written(); await gate; if (rollback) throw rollbackError;
}).catch(error => { if (error !== rollbackError) throw error; });
await Promise.race([staged, writing]);
try {
await vi.advanceTimersByTimeAsync(60_000);
expect(await db.select().from(toolGatewaySessions)).toEqual([]);
expect(await db.select().from(toolMcpGatewayTokens)).toEqual([]);
} finally { release(); }
await writing; await settle();
if (rollback) await quiet();
else {
expect(coordinator!.nextWakeAt()).toBe(await gateway.nextCredentialExpiryAt());
await settle(5 * 60_000); await quiet();
}
});
it("reconstructs saved token expiry on startup", async () => {
const due = new Date(Date.now() + 5 * 60_000);
await token(due);
await start(); await settle();
expect(coordinator!.nextWakeAt()).toBe(due.getTime());
await settle(5 * 60_000);
expect(await db.select().from(toolMcpGatewayTokens)).toEqual([]);
await quiet();
});
it("drains an overdue persisted backlog across bounded batches", async () => {
await db.insert(toolMcpGatewayTokens).values(Array.from({ length: 501 }, (_, index) => ({
companyId, gatewayId: namedId, name: `Expired ${index}`, tokenHash: randomUUID(),
tokenPrefix: "expired", expiresAt: new Date(Date.now() - 1),
})));
await start();
expect(coordinator!.nextWakeAt()).not.toBeNull();
await quiet();
expect(await db.select().from(toolMcpGatewayTokens)).toEqual([]);
});
it("retains cleanup through drain without permitting expired authentication", async () => {
await start(); await quiet(); draining = true;
const active = await session();
const expiredToken = await token(new Date(Date.now() + 3 * 60_000));
await settle();
const reads = vi.spyOn(db, "execute"), selects = vi.spyOn(db, "select");
await settle(10 * 60_000);
expect(reads).not.toHaveBeenCalled(); expect(selects).not.toHaveBeenCalled();
reads.mockRestore(); selects.mockRestore();
expect(await db.select().from(toolGatewaySessions)).toHaveLength(1);
await expect(gateway.listToolsForSession(active.token)).rejects.toMatchObject({ status: 401, reasonCode: "session_expired" });
await expect(gateway.initializeNamedGatewayProtocol({ gatewayId: namedId, bearerToken: expiredToken.token }))
.rejects.toMatchObject({ status: 401 });
draining = false; await settle();
expect(await db.select().from(toolGatewaySessions)).toEqual([]);
expect(await db.select().from(toolMcpGatewayTokens)).toEqual([]);
await quiet();
});
});
@@ -68,6 +68,7 @@ const {
toolGateway: {
sweepActionReviews: vi.fn(async () => ({ scanned: 0 })),
cleanupExpiredSessions: vi.fn(async () => ({ deletedCount: 0 })),
nextCredentialExpiryAt: vi.fn(async () => null),
},
toolActionDeliveries: { hasPending: vi.fn(async () => false), sweepPending: vi.fn(async () => ({ scanned: 0, delivered: 0 })) },
},
+5 -1
View File
@@ -1,3 +1,4 @@
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { createHash, randomUUID } from "node:crypto";
import { createServer, type IncomingMessage } from "node:http";
import express from "express";
@@ -65,7 +66,8 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const externalTestDatabase = resolveExternalTestDatabaseUrl("PAPERCLIP_TOOL_GATEWAY_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalTestDatabase ? { supported: true } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const testToolActionSigningSecret = "test-tool-action-signing-secret";
@@ -553,6 +555,7 @@ describeEmbeddedPostgres("tool gateway acceptance", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
if (externalTestDatabase) { db = createDb(externalTestDatabase); return; }
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-gateway-");
db = createDb(tempDb.connectionString);
}, 90_000);
@@ -592,6 +595,7 @@ describeEmbeddedPostgres("tool gateway acceptance", () => {
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
+5 -2
View File
@@ -1,3 +1,4 @@
import { registerGatewayCredentialExpiryWork } from "./services/gateway-credential-expiry-work.js";
import { registerSecretProposalExpiryWork } from "./services/secret-proposal-expiry-work.js";
import { registerCustomImageCleanupWork } from "./services/custom-image-cleanup-work.js";
import { registerEnvironmentCleanupWork } from "./services/environment-cleanup-work.js";
@@ -1436,7 +1437,10 @@ async function startServerWithDatabaseTeardown(
};
await timeStartupRecovery("tool_reviews", () => app.locals.toolGateway.sweepActionReviews()).catch((err: unknown) => logger.error({ err }, "startup tool review recovery failed"));
await timeStartupRecovery("gateway_sessions", () => app.locals.toolGateway.cleanupExpiredSessions()).catch((err: unknown) => logger.error({ err }, "startup gateway token cleanup failed"));
await timeStartupRecovery("gateway_sessions", () => config.heartbeatSchedulerEnabled
? registerGatewayCredentialExpiryWork(deliveryWork, app.locals.toolGateway,
() => !isIdleTaskDrainActive() && !heartbeatSchedulerStopped && !isWarmStandby()).ready
: app.locals.toolGateway.cleanupExpiredSessions()).catch((err: unknown) => logger.error({ err }, "startup gateway token cleanup failed"));
await timeStartupRecovery("delivery_workers", () => Promise.all([budgetEnforcementWorker, accountingWorker, fastResponseWorker, ...deliveryWorkers].map(worker => worker.ready)));
scheduleGitHubConnectionEventPoll();
scheduleGitHubConnectionContinuitySweep();
@@ -1857,7 +1861,6 @@ async function startServerWithDatabaseTeardown(
}));
trackHeartbeatSchedulerWork(app.locals.toolGateway.sweepActionReviews().catch((err: unknown) => logger.error({ err }, "tool review recovery failed")));
trackHeartbeatSchedulerWork(app.locals.toolGateway.cleanupExpiredSessions().catch((err: unknown) => logger.error({ err }, "gateway token cleanup failed")));
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
if (!(await heartbeat.resolveSchedulingSuppression()).suppressed) {
@@ -8,6 +8,7 @@ export const DELIVERY_QUEUES = {
executionStatus: "execution-status",
executionDisposition: "execution-disposition",
environmentCleanup: "environment-cleanup",
gatewayCredentialExpiry: "gateway-credential-expiry",
secretProposalExpiry: "secret-proposal-expiry",
customImageExpiry: "custom-image-expiry",
historicalCleanup: "historical-cleanup",
@@ -0,0 +1,17 @@
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { registerDeadlineWork } from "./deadline-work.js";
import type { createToolGatewayService } from "./tool-gateway.js";
export function registerGatewayCredentialExpiryWork(
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
gateway: Pick<ReturnType<typeof createToolGatewayService>, "cleanupExpiredSessions" | "nextCredentialExpiryAt">,
canRun: () => boolean,
) {
return registerDeadlineWork(coordinator, DELIVERY_QUEUES.gatewayCredentialExpiry, {
retryMs: 30_000,
canRun,
run: () => gateway.cleanupExpiredSessions(),
next: () => gateway.nextCredentialExpiryAt(),
});
}
+67 -50
View File
@@ -9020,44 +9020,47 @@ export function createToolGatewayService(
const token = generateNamedGatewayToken(tokenId);
const tokenPrefix = `pcgw_${tokenId.slice(0, 8)}`;
const now = new Date();
const [row] = await db
.insert(toolMcpGatewayTokens)
.values({
id: tokenId,
companyId: input.companyId,
gatewayId: input.gatewayId,
name: input.body.name,
tokenHash: hashGatewayToken(token),
tokenPrefix,
subjectType: input.body.subjectType ?? "gateway_client",
subjectId: input.body.subjectId ?? null,
clientLabel: input.body.clientLabel,
ownerNote: input.body.ownerNote,
allowedActions: input.body.allowedActions ?? [
"tools/list",
"tools/call",
"resources/list",
"resources/read",
"prompts/list",
"prompts/get",
],
expiresAt: input.body.expiresAt ?? null,
expiryOverrideReason: input.body.expiryOverrideReason ?? null,
expiryOverrideByAgentId:
input.actor?.agentId && input.body.expiryOverrideReason
? input.actor.agentId
: null,
expiryOverrideByUserId:
input.actor?.userId && input.body.expiryOverrideReason
? input.actor.userId
: null,
expiryOverrideAt: input.body.expiryOverrideReason ? now : null,
createdByAgentId: input.actor?.agentId ?? null,
createdByUserId: input.actor?.userId ?? null,
createdAt: now,
updatedAt: now,
})
.returning();
const [row] = await db.transaction(async tx => {
if (input.body.expiresAt) await notifyDeliveryWork(tx, DELIVERY_QUEUES.gatewayCredentialExpiry);
return tx
.insert(toolMcpGatewayTokens)
.values({
id: tokenId,
companyId: input.companyId,
gatewayId: input.gatewayId,
name: input.body.name,
tokenHash: hashGatewayToken(token),
tokenPrefix,
subjectType: input.body.subjectType ?? "gateway_client",
subjectId: input.body.subjectId ?? null,
clientLabel: input.body.clientLabel,
ownerNote: input.body.ownerNote,
allowedActions: input.body.allowedActions ?? [
"tools/list",
"tools/call",
"resources/list",
"resources/read",
"prompts/list",
"prompts/get",
],
expiresAt: input.body.expiresAt ?? null,
expiryOverrideReason: input.body.expiryOverrideReason ?? null,
expiryOverrideByAgentId:
input.actor?.agentId && input.body.expiryOverrideReason
? input.actor.agentId
: null,
expiryOverrideByUserId:
input.actor?.userId && input.body.expiryOverrideReason
? input.actor.userId
: null,
expiryOverrideAt: input.body.expiryOverrideReason ? now : null,
createdByAgentId: input.actor?.agentId ?? null,
createdByUserId: input.actor?.userId ?? null,
createdAt: now,
updatedAt: now,
})
.returning();
});
return { ...toGatewayToken(row), token };
},
@@ -9174,18 +9177,21 @@ export function createToolGatewayService(
expiresAt: new Date(now.getTime() + sessionTtlMs(input.ttlMs)),
};
await db.insert(toolGatewaySessions).values({
id: session.id,
companyId: session.companyId,
agentId: input.agentId,
runId: input.runId,
issueId: session.issueId,
projectId: session.projectId,
tokenHash: hashGatewayToken(token),
expiresAt: session.expiresAt,
createdAt: session.createdAt,
updatedAt: session.createdAt,
} as any);
await db.transaction(async tx => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.gatewayCredentialExpiry);
await tx.insert(toolGatewaySessions).values({
id: session.id,
companyId: session.companyId,
agentId: input.agentId,
runId: input.runId,
issueId: session.issueId,
projectId: session.projectId,
tokenHash: hashGatewayToken(token),
expiresAt: session.expiresAt,
createdAt: session.createdAt,
updatedAt: session.createdAt,
} as any);
});
await writeAudit({
session,
@@ -11599,6 +11605,17 @@ export function createToolGatewayService(
return { ...sessionView, revokedAt: session!.revokedAt ?? now };
},
async nextCredentialExpiryAt(): Promise<number | null> {
const [row] = await db.execute<{ at: string | Date | null }>(sql`
select min(expires_at) as at from (
select ${toolMcpGatewayTokens.expiresAt} as expires_at from ${toolMcpGatewayTokens}
union all
select ${toolGatewaySessions.expiresAt} as expires_at from ${toolGatewaySessions}
) expiries
`);
return row?.at == null ? null : new Date(row.at).getTime();
},
async cleanupExpiredSessions(input: { now?: Date } = {}) {
const now = input.now ?? new Date();
const expiredTokens = await db.select({ id: toolMcpGatewayTokens.id })