mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
Automatically import skills from new GitHub project repositories and update enabled sources in the background. Preserve project-derived access, saved selections, immutable execution versions and explicit pins; add legacy adoption, refresh and connection repair in Sources. Include durable scheduling and bounded work credits, production Storybook journeys, real GitHub-to-routine acceptance receipts and final-head CI/security/Greptile qualification. Co-Authored-By: Paperclip <noreply@paperclip.ing>
451 lines
17 KiB
TypeScript
451 lines
17 KiB
TypeScript
import { skillAccessService } from "../services/skill-access.js";
|
|
import { createHash } from "node:crypto";
|
|
import type { IncomingMessage, Server as HttpServer } from "node:http";
|
|
import { createRequire } from "node:module";
|
|
import type { Duplex } from "node:stream";
|
|
import { and, eq, isNull } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { agentApiKeys, companyMemberships, instanceUserRoles, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
|
import type { DeploymentMode, LiveEvent } from "@paperclipai/shared";
|
|
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { activityToastIdentity } from "./activity-toast-identity.js";
|
|
import { taskToastEligible } from "./task-toast-eligibility.js";
|
|
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
|
|
import { trackIdleWork } from "../services/task-admission.js";
|
|
|
|
interface WsSocket {
|
|
readyState: number;
|
|
ping(): void;
|
|
send(data: string): void;
|
|
terminate(): void;
|
|
close(code?: number, reason?: string): void;
|
|
on(event: "pong", listener: () => void): void;
|
|
on(event: "close", listener: () => void): void;
|
|
on(event: "error", listener: (err: Error) => void): void;
|
|
}
|
|
|
|
interface WsServer {
|
|
clients: Set<WsSocket>;
|
|
on(event: "connection", listener: (socket: WsSocket, req: IncomingMessage) => void): void;
|
|
on(event: "close", listener: () => void): void;
|
|
handleUpgrade(
|
|
req: IncomingMessage,
|
|
socket: Duplex,
|
|
head: Buffer,
|
|
callback: (ws: WsSocket) => void,
|
|
): void;
|
|
emit(event: "connection", ws: WsSocket, req: IncomingMessage): boolean;
|
|
}
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const { WebSocket, WebSocketServer } = require("ws") as {
|
|
WebSocket: { OPEN: number };
|
|
WebSocketServer: new (opts: { noServer: boolean }) => WsServer;
|
|
};
|
|
|
|
import { authorizationService, type AuthorizationActor } from "../services/authorization.js";
|
|
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
|
|
|
|
interface UpgradeContext {
|
|
actor: AuthorizationActor;
|
|
apiKeyId?: string;
|
|
companyId: string;
|
|
actorType: "board" | "agent";
|
|
actorId: string;
|
|
}
|
|
|
|
/** Cloud-proxied browser identity resolved from trusted x-paperclip-cloud-* headers. */
|
|
export interface CloudUpgradeActor {
|
|
userId: string;
|
|
/** Companies this actor may subscribe to (primary stack company + real memberships). */
|
|
companyIds: string[];
|
|
}
|
|
|
|
interface IncomingMessageWithContext extends IncomingMessage {
|
|
paperclipWebSocketHandled?: boolean;
|
|
paperclipUpgradeContext?: UpgradeContext;
|
|
}
|
|
|
|
function hashToken(token: string) {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
function isWritableUpgradeSocket(socket: Duplex) {
|
|
const maybeWritableState = socket as Duplex & { writable?: boolean; writableEnded?: boolean; writableDestroyed?: boolean };
|
|
return !socket.destroyed && maybeWritableState.writable !== false && !maybeWritableState.writableEnded && !maybeWritableState.writableDestroyed;
|
|
}
|
|
|
|
function closeUpgradeSocket(socket: Duplex) {
|
|
if (!socket.destroyed) {
|
|
socket.destroy();
|
|
}
|
|
}
|
|
|
|
function rejectUpgrade(socket: Duplex, statusLine: string, message: string) {
|
|
const safe = message.replace(/[\r\n]+/g, " ").trim();
|
|
if (!isWritableUpgradeSocket(socket)) {
|
|
closeUpgradeSocket(socket);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
socket.once("finish", () => closeUpgradeSocket(socket));
|
|
socket.end(`HTTP/1.1 ${statusLine}\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${safe}`);
|
|
} catch (err) {
|
|
logger.warn({ err }, "failed to reject live websocket upgrade");
|
|
closeUpgradeSocket(socket);
|
|
}
|
|
}
|
|
|
|
function parseCompanyId(pathname: string) {
|
|
const match = pathname.match(/^\/api\/companies\/([^/]+)\/events\/ws$/);
|
|
if (!match) return null;
|
|
|
|
try {
|
|
return decodeURIComponent(match[1] ?? "");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseBearerToken(rawAuth: string | string[] | undefined) {
|
|
const auth = Array.isArray(rawAuth) ? rawAuth[0] : rawAuth;
|
|
if (!auth) return null;
|
|
if (!auth.toLowerCase().startsWith("bearer ")) return null;
|
|
const token = auth.slice("bearer ".length).trim();
|
|
return token.length > 0 ? token : null;
|
|
}
|
|
|
|
function headersFromIncomingMessage(req: IncomingMessage): Headers {
|
|
const headers = new Headers();
|
|
for (const [key, raw] of Object.entries(req.headers)) {
|
|
if (!raw) continue;
|
|
if (Array.isArray(raw)) {
|
|
for (const value of raw) headers.append(key, value);
|
|
continue;
|
|
}
|
|
headers.set(key, raw);
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
async function authorizeUpgrade(
|
|
db: Db,
|
|
req: IncomingMessage,
|
|
companyId: string,
|
|
url: URL,
|
|
opts: {
|
|
deploymentMode: DeploymentMode;
|
|
resolveSessionFromHeaders?: (headers: Headers) => Promise<BetterAuthSessionResult | null>;
|
|
resolveCloudActor?: (req: IncomingMessage) => Promise<CloudUpgradeActor | null>;
|
|
},
|
|
): Promise<UpgradeContext | null> {
|
|
const queryToken = url.searchParams.get("token")?.trim() ?? "";
|
|
const authToken = parseBearerToken(req.headers.authorization);
|
|
const token = authToken ?? (queryToken.length > 0 ? queryToken : null);
|
|
|
|
// Browser board context has no bearer token in local_trusted and authenticated modes.
|
|
if (!token) {
|
|
if (opts.deploymentMode === "local_trusted") {
|
|
return {
|
|
companyId,
|
|
actorType: "board",
|
|
actorId: "board",
|
|
actor: { type: "board", userId: "local-board", source: "local_implicit" },
|
|
};
|
|
}
|
|
|
|
// Cloud-managed deployments authenticate proxied browsers with trusted
|
|
// x-paperclip-cloud-* headers, never a local Better Auth session — the
|
|
// session fallback below can only 403 them, which left the live-events
|
|
// socket permanently unreachable behind the Cloud front door. A resolved
|
|
// cloud actor is authoritative: authorize against its membership scope.
|
|
// Absent/invalid cloud headers fall through to the session path, so
|
|
// self-hosted behavior is unchanged.
|
|
if (opts.resolveCloudActor) {
|
|
const cloudActor = await opts.resolveCloudActor(req);
|
|
if (cloudActor) {
|
|
if (!cloudActor.companyIds.includes(companyId)) return null;
|
|
return {
|
|
companyId,
|
|
actorType: "board",
|
|
actorId: cloudActor.userId,
|
|
actor: { type: "board", userId: cloudActor.userId, source: "cloud_tenant", companyIds: cloudActor.companyIds },
|
|
};
|
|
}
|
|
}
|
|
|
|
if (opts.deploymentMode !== "authenticated" || !opts.resolveSessionFromHeaders) {
|
|
return null;
|
|
}
|
|
|
|
const session = await opts.resolveSessionFromHeaders(headersFromIncomingMessage(req));
|
|
const userId = session?.user?.id;
|
|
if (!userId) return null;
|
|
|
|
const [roleRow, memberships] = await Promise.all([
|
|
db
|
|
.select({ id: instanceUserRoles.id })
|
|
.from(instanceUserRoles)
|
|
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
|
.then((rows) => rows[0] ?? null),
|
|
db
|
|
.select({ companyId: companyMemberships.companyId })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, userId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
),
|
|
]);
|
|
|
|
const hasCompanyMembership = memberships.some((row) => row.companyId === companyId);
|
|
if (!roleRow && !hasCompanyMembership) return null;
|
|
|
|
return {
|
|
companyId,
|
|
actorType: "board",
|
|
actorId: userId,
|
|
actor: { type: "board", userId, source: "session", companyIds: memberships.map(m => m.companyId) },
|
|
};
|
|
}
|
|
|
|
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 || key.companyId !== companyId || key.scopeConfig) {
|
|
return null;
|
|
}
|
|
|
|
await db
|
|
.update(agentApiKeys)
|
|
.set({ lastUsedAt: new Date() })
|
|
.where(eq(agentApiKeys.id, key.id));
|
|
|
|
return {
|
|
companyId,
|
|
actorType: "agent",
|
|
actorId: key.agentId,
|
|
apiKeyId: key.id,
|
|
actor: { type: "agent", agentId: key.agentId, companyId, source: "agent_key", onBehalfOfUserId: key.responsibleUserId },
|
|
};
|
|
}
|
|
|
|
export function setupLiveEventsWebSocketServer(
|
|
server: HttpServer,
|
|
db: Db,
|
|
opts: {
|
|
deploymentMode: DeploymentMode;
|
|
resolveSessionFromHeaders?: (headers: Headers) => Promise<BetterAuthSessionResult | null>;
|
|
/**
|
|
* Resolves a Cloud-proxied browser's identity from the trusted
|
|
* x-paperclip-cloud-* headers on the upgrade request. Wired by managed
|
|
* deployments; self-hosted instances leave it unset.
|
|
*/
|
|
resolveCloudActor?: (req: IncomingMessage) => Promise<CloudUpgradeActor | null>;
|
|
},
|
|
) {
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
const cleanupByClient = new Map<WsSocket, () => void>();
|
|
const aliveByClient = new Map<WsSocket, boolean>();
|
|
|
|
const pingInterval = setInterval(() => {
|
|
for (const socket of wss.clients) {
|
|
if (!aliveByClient.get(socket)) {
|
|
socket.terminate();
|
|
continue;
|
|
}
|
|
aliveByClient.set(socket, false);
|
|
socket.ping();
|
|
}
|
|
}, 30000);
|
|
|
|
wss.on("connection", (socket: WsSocket, req: IncomingMessage) => {
|
|
const context = (req as IncomingMessageWithContext).paperclipUpgradeContext;
|
|
if (!context) {
|
|
socket.close(1008, "missing context");
|
|
return;
|
|
}
|
|
|
|
const access = authorizationService(db);
|
|
async function visibleEvent(event: LiveEvent): Promise<LiveEvent | null> {
|
|
if (context!.actor.source === "local_implicit") {
|
|
if (event.type === "activity.logged" && event.payload.entityType === "issue") {
|
|
return { ...event, payload: { ...event.payload,
|
|
...await activityToastIdentity(db, event.companyId, event.payload) } };
|
|
}
|
|
return event;
|
|
}
|
|
if (context!.apiKeyId) {
|
|
const active = await db.select({ id: agentApiKeys.id }).from(agentApiKeys)
|
|
.where(and(eq(agentApiKeys.id, context!.apiKeyId), isNull(agentApiKeys.revokedAt))).limit(1);
|
|
if (!active.length) return null;
|
|
} else {
|
|
const membership = await db.select({ id: companyMemberships.id }).from(companyMemberships)
|
|
.where(and(eq(companyMemberships.companyId, context!.companyId), eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, context!.actorId), eq(companyMemberships.status, "active"))).limit(1);
|
|
const admin = context!.actor.source !== "cloud_tenant" && await db.select({ id: instanceUserRoles.id }).from(instanceUserRoles)
|
|
.where(and(eq(instanceUserRoles.userId, context!.actorId), eq(instanceUserRoles.role, "instance_admin"))).limit(1).then(rows => rows.length > 0);
|
|
if (!membership.length && !admin) return null;
|
|
}
|
|
const payload = event.payload;
|
|
const issueId = typeof payload.issueId === "string" ? payload.issueId
|
|
: payload.entityType === "issue" && typeof payload.entityId === "string" ? payload.entityId : null;
|
|
if (issueId) {
|
|
const [issue] = await db.select({ id: issues.id }).from(issues)
|
|
.where(and(eq(issues.id, issueId), eq(issues.companyId, event.companyId))).limit(1);
|
|
if (!issue || !(await access.decide({ actor: context!.actor, action: "issue:read",
|
|
resource: { type: "issue", companyId: event.companyId, issueId } })).allowed) return null;
|
|
}
|
|
if (["company_skill", "company_skill_source"].includes(String(payload.entityType)) && typeof payload.entityId === "string") {
|
|
try {
|
|
const skills = skillAccessService(db, context!.actor);
|
|
if (payload.entityType === "company_skill") await skills.assertSkill(event.companyId, payload.entityId);
|
|
else await skills.assertSource(event.companyId, payload.entityId);
|
|
} catch { return null; }
|
|
}
|
|
if (payload.entityType === "project" && typeof payload.entityId === "string") {
|
|
const [project] = await db.select({ id: projects.id }).from(projects)
|
|
.where(and(eq(projects.id, payload.entityId), eq(projects.companyId, event.companyId))).limit(1);
|
|
if (!project || !(await access.decide({ actor: context!.actor, action: "project:read",
|
|
resource: { type: "project", companyId: event.companyId, projectId: project.id } })).allowed) return null;
|
|
}
|
|
const runId = typeof payload.runId === "string" ? payload.runId
|
|
: payload.entityType === "heartbeat_run" && typeof payload.entityId === "string" ? payload.entityId : null;
|
|
if (runId) {
|
|
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, event.companyId)));
|
|
if (!run || !(await canActorReadHeartbeatRun(db, access, context!.actor, run))) return null;
|
|
}
|
|
if (event.type.startsWith("heartbeat.run.")) return runId ? event : null;
|
|
if (event.type === "agent.session.goal.changed") return issueId ? event : null;
|
|
// Company-wide invalidations carry no task-derived content. Authorized
|
|
// clients obtain details through viewer-filtered HTTP reads.
|
|
if (event.type === "activity.logged") return { ...event, payload: {
|
|
action: payload.action, entityType: payload.entityType, entityId: payload.entityId,
|
|
// The task read check above authorizes attribution, but not arbitrary details.
|
|
...(issueId ? await activityToastIdentity(db, event.companyId, payload) : {}),
|
|
} };
|
|
if (event.type === "agent.status") return { ...event, payload: { agentId: payload.agentId, status: payload.status } };
|
|
if (event.type === "external_object.updated") return { ...event, payload: { externalObjectId: payload.externalObjectId } };
|
|
if (event.type.startsWith("plugin.")) return event;
|
|
return null;
|
|
}
|
|
let delivery = Promise.resolve();
|
|
let pending = 0;
|
|
const unsubscribe = subscribeCompanyLiveEvents(context.companyId, (event) => {
|
|
if (socket.readyState !== WebSocket.OPEN) return;
|
|
if (++pending > 1000) { socket.close(1013, "event backlog"); return; }
|
|
delivery = delivery.then(async () => {
|
|
if (socket.readyState !== WebSocket.OPEN) return;
|
|
// Check at delivery time, including after a grant is revoked while the
|
|
// subscription remains open. Never cache successful privacy decisions.
|
|
const visible = await visibleEvent(event);
|
|
if (visible) {
|
|
// Relevance is independent of authorization and cache delivery. A
|
|
// failed relevance lookup must keep the authorized update flowing.
|
|
let toastEligible = false;
|
|
try {
|
|
toastEligible = await taskToastEligible(db, visible,
|
|
context.actor.type === "board" ? context.actor.userId ?? null : null);
|
|
} catch (err) {
|
|
logger.warn({ err, companyId: context.companyId }, "task toast eligibility failed");
|
|
}
|
|
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ ...visible, toastEligible }));
|
|
}
|
|
}).catch(err => logger.warn({ err, companyId: context.companyId }, "live event authorization failed"))
|
|
.finally(() => { pending -= 1; });
|
|
});
|
|
|
|
cleanupByClient.set(socket, unsubscribe);
|
|
aliveByClient.set(socket, true);
|
|
|
|
socket.on("pong", () => {
|
|
aliveByClient.set(socket, true);
|
|
});
|
|
|
|
socket.on("close", () => {
|
|
const cleanup = cleanupByClient.get(socket);
|
|
if (cleanup) cleanup();
|
|
cleanupByClient.delete(socket);
|
|
aliveByClient.delete(socket);
|
|
});
|
|
|
|
socket.on("error", (err: Error) => {
|
|
logger.warn({ err, companyId: context.companyId }, "live websocket client error");
|
|
});
|
|
});
|
|
|
|
wss.on("close", () => {
|
|
clearInterval(pingInterval);
|
|
});
|
|
|
|
server.on("upgrade", (req, socket, head) => {
|
|
if ((req as IncomingMessageWithContext).paperclipWebSocketHandled) {
|
|
return;
|
|
}
|
|
|
|
const onRawSocketError = (err: Error) => {
|
|
logger.warn({ err, path: req.url }, "live websocket upgrade socket error");
|
|
};
|
|
const cleanupRawSocketListeners = () => {
|
|
socket.off("error", onRawSocketError);
|
|
socket.off("close", cleanupRawSocketListeners);
|
|
};
|
|
|
|
socket.on("error", onRawSocketError);
|
|
socket.once("close", cleanupRawSocketListeners);
|
|
|
|
if (!req.url) {
|
|
rejectUpgrade(socket, "400 Bad Request", "missing url");
|
|
return;
|
|
}
|
|
|
|
const url = new URL(req.url, "http://localhost");
|
|
const companyId = parseCompanyId(url.pathname);
|
|
if (!companyId) {
|
|
closeUpgradeSocket(socket);
|
|
return;
|
|
}
|
|
|
|
// Upgrade admission precedes this async authentication. An idle hold or
|
|
// socket close must not make its accepted database writes disappear.
|
|
void trackIdleWork(authorizeUpgrade(db, req, companyId, url, {
|
|
deploymentMode: opts.deploymentMode,
|
|
resolveSessionFromHeaders: opts.resolveSessionFromHeaders,
|
|
resolveCloudActor: opts.resolveCloudActor,
|
|
})
|
|
.then((context) => {
|
|
if (!context) {
|
|
rejectUpgrade(socket, "403 Forbidden", "forbidden");
|
|
return;
|
|
}
|
|
|
|
if (!isWritableUpgradeSocket(socket)) {
|
|
cleanupRawSocketListeners();
|
|
return;
|
|
}
|
|
|
|
const reqWithContext = req as IncomingMessageWithContext;
|
|
reqWithContext.paperclipUpgradeContext = context;
|
|
|
|
cleanupRawSocketListeners();
|
|
wss.handleUpgrade(req, socket, head, (ws: WsSocket) => {
|
|
wss.emit("connection", ws, reqWithContext);
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
logger.error({ err, path: req.url }, "failed websocket upgrade authorization");
|
|
rejectUpgrade(socket, "500 Internal Server Error", "upgrade failed");
|
|
}));
|
|
});
|
|
|
|
return wss;
|
|
}
|