Files
paperclip/server/src/middleware/auth.ts
Forgotten 49e15f056d Implement issue execution lock with deferred wake promotion
Add per-issue execution lock (executionRunId, executionAgentNameKey,
executionLockedAt) to prevent concurrent runs on the same issue.
Same-name wakes are coalesced into the active run; different-name
wakes are deferred and promoted when the lock holder finishes.

Includes checkout/release run ownership enforcement, agent run ID
propagation from JWT claims, wakeup deduplication across assignee
and mention wakes, and claimQueuedRun extraction for reuse. Adds
two DB migrations for checkoutRunId and execution lock columns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:48:22 -06:00

103 lines
2.6 KiB
TypeScript

import { createHash } from "node:crypto";
import type { RequestHandler } from "express";
import { and, eq, isNull } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { agentApiKeys, agents } from "@paperclip/db";
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export function actorMiddleware(db: Db): RequestHandler {
return async (req, _res, next) => {
req.actor = { type: "board", userId: "board" };
const runIdHeader = req.header("x-paperclip-run-id");
const authHeader = req.header("authorization");
if (!authHeader?.toLowerCase().startsWith("bearer ")) {
if (runIdHeader) req.actor.runId = runIdHeader;
next();
return;
}
const token = authHeader.slice("bearer ".length).trim();
if (!token) {
next();
return;
}
const tokenHash = hashToken(token);
const key = await db
.select()
.from(agentApiKeys)
.where(and(eq(agentApiKeys.keyHash, tokenHash), isNull(agentApiKeys.revokedAt)))
.then((rows) => rows[0] ?? null);
if (!key) {
const claims = verifyLocalAgentJwt(token);
if (!claims) {
next();
return;
}
const agentRecord = await db
.select()
.from(agents)
.where(eq(agents.id, claims.sub))
.then((rows) => rows[0] ?? null);
if (!agentRecord || agentRecord.companyId !== claims.company_id) {
next();
return;
}
if (agentRecord.status === "terminated" || agentRecord.status === "pending_approval") {
next();
return;
}
req.actor = {
type: "agent",
agentId: claims.sub,
companyId: claims.company_id,
keyId: undefined,
runId: runIdHeader || claims.run_id || undefined,
};
next();
return;
}
await db
.update(agentApiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(agentApiKeys.id, key.id));
const agentRecord = await db
.select()
.from(agents)
.where(eq(agents.id, key.agentId))
.then((rows) => rows[0] ?? null);
if (!agentRecord || agentRecord.status === "terminated" || agentRecord.status === "pending_approval") {
next();
return;
}
req.actor = {
type: "agent",
agentId: key.agentId,
companyId: key.companyId,
keyId: key.id,
runId: runIdHeader || undefined,
};
next();
};
}
export function requireBoard(req: Express.Request) {
return req.actor.type === "board";
}