mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
fix(server): schedule gateway credential expiry from committed changes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d41590ac0e
commit
75332b9641
10 files changed
+339
-56
No files matched your search
@@ -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 })) },
|
||||
},
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user