Files
PaperClipAI/server/src/realtime/live-events-ws.ts
T
Devin FoleyandPaperclip f406b32d08 perf: narrow run authorization reads (#17348)
## Thinking Path

> - Paperclip lets people manage and observe AI agents at work.
> - Live events and run history must enforce current task permissions.
> - Some permission checks load the complete run record for each event
or request.
> - Run context, runner configuration, results, and output can make
those records large.
> - This pull request reads only the persisted fields each access check
needs.
> - The checks still read current permissions and fail closed.

## Linked Issues or Issue Description

Related: #17251, #9155, and #16229. Those changes cover other run
projections, recovery polls, and task history. This change covers
live-event delivery, workspace-operation run checks, and event-list
access.

**What happened?**

Live-event delivery loaded every run column for each subscribed viewer,
although the permission helper used only the company, run scope, and
task binding. Workspace-operation checks did the same. The run-event
endpoint also loaded complete run records before returning separate
event rows. Large contexts and runner configurations therefore increased
database transfer for ordinary access checks.

**Expected behavior**

Read only the stored access binding. Keep company checks, permission
freshness, deleted-task tombstones, and response bodies unchanged.

**Steps to reproduce**

1. Create an issue-bound run with large context, runner configuration,
and output fields.
2. Subscribe an authorized viewer to company live events and publish run
updates.
3. Read the run events and a linked workspace operation.
4. Inspect the run SELECT projections. The previous queries load fields
that the access checks do not use.

**Paperclip version or commit**

The defect is present on the current master base, `62c78e56e53`.

**Deployment mode**

Authenticated server with PostgreSQL. The defect is not
adapter-specific.

## What Changed

- Select only company, scope, and issue bindings for live-event and
workspace-operation run authorization.
- Add a four-field run-access read for event-list requests. Reuse those
columns in the existing log-access projection.
- Preserve per-delivery and per-request permission checks, company
predicates, and event responses.
- Add real PostgreSQL and WebSocket coverage with large run fixtures.
Cover grant revocation, grant restoration, deleted tasks, missing and
foreign-company runs, and failed reads.
- Document the bounded access reads in the existing polling-load guide.

## Verification

- Six focused suites pass: 40 tests. They cover the changed paths,
run-state reads, run logs, disconnect retries, and native event replay.
- The new WebSocket test uses the production delivery path and a real
database. It verifies that the selected SQL omits large run fields while
authorization changes take effect on the same open socket.
- The event-route test preserves the returned event content and denies
the next request after grant revocation.
- Local recursive typecheck, full build, and module-boundary checks
pass. [Exact-head
CI](https://github.com/paperclipai/paperclip/actions/runs/39997370119)
completed successfully, covering the full grouped unit suites,
typecheck, build/runtime boot, and E2E shards. The separate serial local
`pnpm test:run` is still running and is not claimed as completed.
- A later CI shard failed an unchanged email test expecting exactly one
task after permission grants. The unchanged-base test passes at normal
speed; adding only a 2.2-second pause before granting access reproduces
the extra tasks because previously denied deliveries become due for
retry. No email code or assertions are changed in this PR. The original
failure is retained. The single shard rerun passed: 92 suites and 1,489
tests passed, with one suite and two tests skipped by the existing
gates.
- CI initially found a route-test double missing the new access helper.
The follow-up fixes that fixture and adds event reads to malformed-ID
and missing/cross-company checks. All 122 tests across the four
changed-path suites pass; a final rerun of the expanded route suite
passes all 92 tests.
- The first CI run also timed out in the signoff changes-requested
handoff while waiting for a reviewer run after a cancelled execution
blocker. The focused test passes locally on this source. The same
focused test passes three times on unchanged master. Controller-lease
cancellation messages appear on both versions. The original CI timeout
is preserved as an intermittent, unreproduced failure; its exact cause
remains unknown. The signoff shard passes in the final exact-head CI
run.
- Initial fresh-checkout tests could not start because workspace SDK
output and native PostgreSQL library links were missing. Dependency
builds and the package's native-link setup corrected those
prerequisites. The subsequent focused run passed.

Focused command:

```sh
pnpm --filter @paperclipai/server exec vitest run src/__tests__/live-events-run-read.test.ts src/__tests__/heartbeat-run-privacy-routes.test.ts src/services/heartbeat/run-state.test.ts src/__tests__/native-observer-replay.test.ts src/__tests__/heartbeat-run-log.test.ts src/__tests__/heartbeat-run-privacy-disconnect-retry.test.ts
```

## Risks

- A missing access field could cause an incorrect allow or denial. Tests
exercise current grants, revocation, company scope, and deleted-task
bindings through the real consumers.
- No successful permission decision is cached. Every delivery and
request still checks current database state.
- No schema, migration, polling interval, API response, or deployment
configuration changes are included.
- This reduces bytes for the affected reads. It does not establish a
whole-account savings percentage. Hosted transfer measurements remain
separate validation after staging deployment.

## Model Used

OpenAI Codex, a GPT-6 based coding agent, used repository tools, code
execution, and database-backed tests. The session does not expose the
exact backend model identifier or context-window size; those values are
not guessed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-23 11:01:06 -07:00

461 lines
18 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";
import { canActorReadCase } from "../services/case-access.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 (event.payload.entityType === "case" && (typeof event.payload.entityId !== "string"
|| !(await canActorReadCase(db, context!.actor, { id: event.payload.entityId, companyId: event.companyId })))) return 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({
companyId: heartbeatRuns.companyId,
scopeKind: heartbeatRuns.scopeKind,
issueId: heartbeatRuns.issueId,
}).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,
// A handoff can attach the first case to a task. Its already-authorized
// task identity lets an empty cases panel refresh without exposing content.
...(payload.entityType === "case" && String(payload.action).startsWith("case.handoff_") && issueId ? { issueId } : {}),
// 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;
}