mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Task notifications tell people who changed a task. > - The authenticated event stream removed the actor ID and type. > - The UI then showed “Someone” and could not hide the viewer's own actions. > - Toasts also had no field or renderer for an actor avatar. > - This pull request keeps authorized actor identity and shows the existing person or agent avatar. ## Linked Issues or Issue Description **What happened?** Authenticated task notifications showed “Someone.” A person's own actions could produce notifications. Toasts showed a colored dot instead of the actor's avatar. **Expected behavior** Show the actor's name and avatar. Hide notifications for the viewer's own task actions. **Steps to reproduce** 1. Sign in to an authenticated instance. 2. View a page other than the task being changed. 3. Create or update a task, or add a comment. 4. Observe the generic name and the notification for your own action. ## What Changed - Keep actor IDs and types after the existing task access check. - Read only the actor's name, photo, or agent appearance from company-scoped records. Do not forward task details or comments. - Supply names and avatars even when the browser has no cached directory. - Carry actor data through the toast provider. Use the existing person and agent avatar components. - Identify the local trusted viewer so that self-actions are also hidden there. - Add tests for authenticated event delivery, hidden tasks, private details, self-actions, names, agent appearance, and avatar rendering. ## Verification - Passed: 71 UI tests across LiveUpdatesProvider, ToastContext, and ToastViewport. - Passed: 11 server tests across live-events-toast and live-events-ws. The added tests assert the actual SQL filters and bound company, actor, and active-membership values. - Passed: UI typecheck (`cd ui && ../node_modules/.bin/tsc -b`). - Passed: UI production build (`cd ui && node_modules/.bin/vite build`). - Passed: `pnpm check:token-gates` and `git diff --check`. - The full `pnpm -r typecheck` and `pnpm build` could not complete because this environment has no Rust `cargo` executable. - The full `pnpm test:run` did not complete. Broad validation exceeded the 4 GB sandbox memory limit. The full test run was stopped; the focused tests were then run successfully. - Manual review: use two signed-in people, make task changes from each, and confirm only the other person's toast appears. Repeat with an agent and check its avatar. ### Avatar screenshots Production toast components with sample identities. These isolated browser renders cover the person image and the agent custom avatar in both themes.   ## Risks - Task toast events add a small profile read per authorized viewer. Other task actions retain IDs without a profile read. - Deleted or unavailable identities use an ID-based label. People without a photo use initials. - Access checks and task-detail filtering remain in place. There is no database migration. - All CI checks passed on the first commit, including full typecheck, build, test shards, and browser suites. The updated commit must pass CI again. The PR is ready for review. CI and Greptile must pass on the updated commit. ## Model Used - OpenAI GPT-6 through Codex, with code editing and tool execution. The exact deployment model ID and context window were not exposed to this run. ## 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 and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused suites listed above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (behavior and validation are recorded above; no command or configuration change) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
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 { 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 (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 && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(visible));
|
|
}).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;
|
|
}
|