mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its web server also owns controller admission and user commands. > - A rolling deployment starts a replacement before the old server exits. > - Shared database state alone does not preserve local agent files, browser chunks, or in-process commands. > - This pull request adds an opt-in ephemeral server contract and a local overlap test. > - The benefit is a web server that can relinquish traffic and local files without losing acknowledged work. ## Linked Issues or Issue Description Refs #18403, #18327, #18335, #18341. **Current behavior** The control-plane web server still has local agent-file authority and local command targets. A new container can miss saved bytes or send a command to the wrong process. An existing browser tab also needs chunks from its original build. **Proposed behavior** Eligible servers share only PostgreSQL, object storage and stable secrets. A replacement waits for readiness. The old server drains admitted requests, keeps owner commands available through handoff, and releases controller leases. Explicit maintenance adopts existing files before the volume is removed. **Reason and benefit** This is the application prerequisite for rolling app-only deployments. Schema migrations and permanent multi-replica operation are outside this change. ## What Changed - Store agent-file manifests in PostgreSQL and immutable bytes in S3. Treat local roots as disposable projections. Preserve existing permission checks and edit conflicts. - Add an explicit migration, eligibility and object-retention command to the production runtime bundle. - Add `/api/ready`, admitted-request drain, browser restart signaling and retained immutable UI chunks. - Carry transaction-aware work hints across controllers during handoff and active execution. Keep listeners closed when idle. - Deliver durable Stop and runtime responses to the execution owner. Recheck request lifecycle and access before response dispatch. - Reject local-only configuration in ephemeral mode. Keep the feature disabled for existing installations by default. - Add an isolated PostgreSQL/S3/proxy/browser deployment harness with independent server homes and retained failure evidence. ## Verification - All CI checks pass on `ce17b95019d3cf1cabd357d19b6d37f96782cc5c`: workspace typecheck, build, Runner checks, server/general tests and browser shards. Greptile reviewed the runtime changes at 5/5 with all findings resolved; the final commit updates maps and the plan. - Local targeted checks passed: coordinator 27; Stop/recovery 33; PostgreSQL command delivery 6; route/workspace regressions 8; bootstrap Stop 1; restart recovery 75. PostgreSQL command tests simulate the provider edge. - Local browser proof passed 91 reads and 19 saves; reconnect took 1,013ms. The tab opened Audit from S3 after deletion of A's whole home/build. This uses compiled workspace code and local-trusted auth, not released containers or live agents. - Local workspace typecheck/build passed; current server and runtime bundle passed. The earlier broad local suite was stopped after three hours and is not a passing receipt. Its retained failures and isolated reruns are documented in the plan. - Stacked #18526 adds stricter graceful-exit proof, an unready replacement control, live Runner qualification and a Stop handoff correction. Hosted enablement remains gated on those results and Cloud #1096. No hosted stack has been enabled. ## Risks - Apply migration 0422 before enabling the feature. Existing data must pass each owner's migration and eligibility checks. Do not discard a volume based on a single migration page. - Durable manifests remain authoritative after the opt-in is disabled. Rolling back to a binary that ignores them is unsupported. - Cold directory restoration and complete shutdown timing still need qualification before enabling a hosted stack. - Retained UI assets must cover the supported browser and rollback window. Cleanup is explicit; no new idle maintenance polling is introduced. - This change does not claim production zero downtime or authorize a fleet-wide enablement. ## Model Used OpenAI Codex, GPT-6 family. The exact serving model ID and context-window limit are not exposed in this session. Used reasoning, code editing, shell execution and browser automation. ## 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>
470 lines
19 KiB
TypeScript
470 lines
19 KiB
TypeScript
import { createLiveEventDelivery } from "./live-event-delivery.js";
|
|
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, 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";
|
|
import { createLiveRunEventRedactor } from "../services/live-run-event-redaction.js";
|
|
|
|
interface WsSocket {
|
|
readyState: number;
|
|
ping(): void;
|
|
bufferedAmount?: number;
|
|
send(data: string, callback?: (error?: Error) => void): void;
|
|
on(event: "message", listener: (data: Buffer, isBinary: boolean) => void): 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>;
|
|
close(): void;
|
|
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; maxPayload: number }) => WsServer;
|
|
};
|
|
|
|
import { authorizationService, type AuthorizationActor } from "../services/authorization.js";
|
|
import { getHeartbeatRunReadDecisions } 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;
|
|
subscribeInvalidation?: (companyId: string, resync: () => void) => () => void;
|
|
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, maxPayload: 64 * 1024 });
|
|
const redactRunEvent = createLiveRunEventRedactor(db);
|
|
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;
|
|
const runId = typeof payload.runId === "string" ? payload.runId
|
|
: payload.entityType === "heartbeat_run" && typeof payload.entityId === "string" ? payload.entityId : null;
|
|
if (event.type === "heartbeat.run.event" || event.type === "heartbeat.run.log") {
|
|
const decision = await access.decide({ actor: context!.actor, action: "company_scope:read",
|
|
resource: { type: "company", companyId: event.companyId } });
|
|
if (!decision.allowed) return null;
|
|
}
|
|
const readBindings: Parameters<typeof getHeartbeatRunReadDecisions>[3][number][] = [];
|
|
if (issueId) {
|
|
readBindings.push({ companyId: event.companyId, scopeKind: "issue", issueId });
|
|
}
|
|
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) return null;
|
|
readBindings.push(run);
|
|
}
|
|
if ((await getHeartbeatRunReadDecisions(db, access, context!.actor, readBindings)).some(allowed => !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;
|
|
}
|
|
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;
|
|
}
|
|
const delivery = createLiveEventDelivery({ socket, authorize: async event => {
|
|
const visible = await visibleEvent(event);
|
|
if (!visible || visible.type !== "heartbeat.run.event") return visible;
|
|
const payload = visible.payload.payload;
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload) || !("prpEvent" in payload)) return visible;
|
|
return redactRunEvent(visible);
|
|
},
|
|
decorate: async visible => {
|
|
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"); }
|
|
return { ...visible, toastEligible };
|
|
},
|
|
onError: err => logger.warn({ err, companyId: context.companyId }, "live event authorization failed"),
|
|
});
|
|
const unsubscribe = subscribeCompanyLiveEvents(context.companyId, event => delivery.enqueue(event));
|
|
const unsubscribeInvalidation = opts.subscribeInvalidation?.(context.companyId, delivery.requestResync);
|
|
socket.on("message", (data, isBinary) => {
|
|
if (isBinary) { socket.close(1003, "text messages required"); return; }
|
|
delivery.receive(data.toString());
|
|
});
|
|
cleanupByClient.set(socket, () => { unsubscribe(); unsubscribeInvalidation?.(); delivery.dispose(); });
|
|
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;
|
|
}
|