diff --git a/package-lock.json b/package-lock.json index b60834ada4..37b09f3c1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -198,6 +198,30 @@ "vite": "^7.3.1" } }, + "../system-canvas/packages/collab": { + "name": "system-canvas-collab", + "version": "0.2.57", + "extraneous": true, + "license": "MIT", + "devDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^19.0.0", + "system-canvas": "^0.2.57", + "typescript": "^5.4.0", + "vitest": "^4.1.7", + "yjs": "^13.6.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "system-canvas": "^0.2.57", + "yjs": "^13.6.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", diff --git a/package.json b/package.json index a3ec51d02b..b163abc8c5 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "validate:e2e-migration": "tsx scripts/validate-e2e-migration.ts", "rotate-keys": "tsx scripts/rotate-encryption-key.ts", "seed:db": "tsx scripts/helpers/seed-database.ts", + "seed:local-swarm": "dotenv -e .env.local -- tsx scripts/seed-local-swarm.ts", "seed:auto-seed": "tsx scripts/seed-from-github-account.ts", "seed:prompts": "tsx scripts/seed-stakwork-prompts.ts", "seed:action-nodes": "tsx scripts/helpers/seed-graph-action-nodes.ts", diff --git a/scripts/seed-local-swarm.ts b/scripts/seed-local-swarm.ts new file mode 100644 index 0000000000..72763fa947 --- /dev/null +++ b/scripts/seed-local-swarm.ts @@ -0,0 +1,112 @@ +/** + * Seed a local mock user + org + workspace + swarm wired for hive-relay. + * + * Mock login already creates the whole chain (via ensureMockWorkspaceForUser) + * but leaves the swarm WITHOUT a `swarmApiKey` and the org WITHOUT a + * `defaultWorkspaceId`. This script fills both so the relay-token flow works: + * - sets a known `swarmApiKey` (encrypted) + a localhost `swarmUrl`, and + * - makes the workspace the org's default (the Org Canvas resolves its + * swarm via org -> defaultWorkspace -> swarm). + * + * Run: + * npx dotenv -e .env.local -- npx tsx scripts/seed-local-swarm.ts + * Optional overrides: + * SEED_USERNAME=dev SEED_SWARM_API_KEY= + * + * Then start hive-relay with the printed SWARM_API_KEY. Idempotent. + */ +// Relative imports (matching the other scripts) so the editor resolves them; +// the `@/` aliases inside these modules still resolve at runtime under tsx. +import { WorkspaceRole } from "@prisma/client"; +import { db } from "../src/lib/db"; +import { ensureMockWorkspaceForUser } from "../src/utils/mockSetup"; +import { saveOrUpdateSwarm } from "../src/services/swarm/db"; + +const USERNAME = process.env.SEED_USERNAME ?? "dev"; +// A second member of the SAME org, so you can log in as two different users +// (in two browsers) and see each other's cursors on /org/. +const SECOND_USERNAME = process.env.SEED_SECOND_USERNAME ?? "dev2"; +// Stable default so re-running the seed doesn't change the key (you set the +// relay's SWARM_API_KEY once). Override with SEED_SWARM_API_KEY for a custom one. +const SWARM_API_KEY = process.env.SEED_SWARM_API_KEY ?? "local-dev-swarm-key"; + +async function main() { + const userId = `mock-${USERNAME}`; + + // 1. The mock user (mock login creates this on sign-in; we do it up-front + // so the seed is runnable before any login). + await db.user.upsert({ + where: { id: userId }, + update: {}, + create: { id: userId, name: USERNAME, email: `${USERNAME}@mock.dev` }, + }); + + // 2. Org + workspace + repository + swarm (the real mock setup). Idempotent: + // returns early if the user already has a workspace. + const slug = await ensureMockWorkspaceForUser(userId); + const workspace = await db.workspace.findUnique({ + where: { slug }, + select: { id: true, sourceControlOrgId: true }, + }); + if (!workspace) throw new Error(`workspace not found for slug ${slug}`); + + // 3. Set a known swarmApiKey (encrypted by saveOrUpdateSwarm) + a localhost + // swarmUrl so getRelayUrl() -> http://localhost:3333. + await saveOrUpdateSwarm({ + workspaceId: workspace.id, + swarmApiKey: SWARM_API_KEY, + swarmUrl: "http://localhost/api", + }); + + // 4. Make this workspace the org's default so the Org Canvas can resolve the + // swarm (org -> defaultWorkspace -> swarm). + let orgLogin = "(no org linked)"; + if (workspace.sourceControlOrgId) { + const org = await db.sourceControlOrg.update({ + where: { id: workspace.sourceControlOrgId }, + data: { defaultWorkspaceId: workspace.id }, + select: { githubLogin: true }, + }); + orgLogin = org.githubLogin; + } + + // 5. A second user, added as a member of the same workspace → both belong to + // the org, so 2-user presence works on /org/. + const secondId = `mock-${SECOND_USERNAME}`; + await db.user.upsert({ + where: { id: secondId }, + update: {}, + create: { + id: secondId, + name: SECOND_USERNAME, + email: `${SECOND_USERNAME}@mock.dev`, + }, + }); + await db.workspaceMember.upsert({ + where: { workspaceId_userId: { workspaceId: workspace.id, userId: secondId } }, + update: { role: WorkspaceRole.DEVELOPER, leftAt: null }, + create: { + workspaceId: workspace.id, + userId: secondId, + role: WorkspaceRole.DEVELOPER, + }, + }); + + console.log("\nāœ… Local swarm ready for hive-relay\n"); + console.log(" mock logins :", USERNAME, "(owner) &", SECOND_USERNAME, "(member)"); + console.log(" workspace slug :", slug); + console.log(" org (githubLogin) :", orgLogin, " -> /org/" + orgLogin); + console.log(" SWARM_API_KEY :", SWARM_API_KEY); + console.log("\n Start hive-relay with:"); + console.log( + ` SWARM_API_KEY=${SWARM_API_KEY} CORS_ORIGIN=http://localhost:3000 PORT=3333 npm run dev`, + ); + console.log(""); +} + +main() + .then(() => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/src/__tests__/unit/canvas/OrgCanvasBackground.multiSelectKey.test.tsx b/src/__tests__/unit/canvas/OrgCanvasBackground.multiSelectKey.test.tsx index 8c877263dd..479aacf238 100644 --- a/src/__tests__/unit/canvas/OrgCanvasBackground.multiSelectKey.test.tsx +++ b/src/__tests__/unit/canvas/OrgCanvasBackground.multiSelectKey.test.tsx @@ -33,11 +33,9 @@ vi.mock("@/hooks/useWorkspace", () => ({ }), })); -vi.mock("@/hooks/useCanvasCollaboration", () => ({ - useCanvasCollaboration: () => ({ +vi.mock("@/hooks/useCanvasCollaborationViaRelay", () => ({ + useCanvasCollaborationViaRelay: () => ({ collaborators: [], - broadcastCursor: vi.fn(), - broadcastSelection: vi.fn(), }), })); diff --git a/src/app/api/orgs/[githubLogin]/canvas/relay-token/route.ts b/src/app/api/orgs/[githubLogin]/canvas/relay-token/route.ts new file mode 100644 index 0000000000..641890324a --- /dev/null +++ b/src/app/api/orgs/[githubLogin]/canvas/relay-token/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { validateUserBelongsToOrg } from "@/services/workspace"; +import { getSwarmAccessByWorkspaceId } from "@/lib/helpers/swarm-access"; +import { getMiddlewareContext, requireAuth } from "@/lib/middleware/utils"; +import { signRelayToken } from "@/lib/relay-token"; +import { getRelayUrl } from "@/lib/utils/swarm"; + +const TOKEN_TTL_SECONDS = 300; + +/** + * GET /api/orgs/[githubLogin]/canvas/relay-token + * + * Issues a short-lived JWT the browser passes in the socket.io handshake + * to the per-swarm relay. Capability-scoped to exactly this org's canvas + * via the `resource` claim (`canvas:`), so the relay only + * lets the socket join that one room. Signed HS256 with the org's home + * swarm's `swarmApiKey` (org -> defaultWorkspace -> swarm). + * + * Returns { token, url, expiresInSeconds }. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ githubLogin: string }> }, +) { + try { + const context = getMiddlewareContext(request); + const userOrResponse = requireAuth(context); + if (userOrResponse instanceof NextResponse) return userOrResponse; + + const { githubLogin } = await params; + + const isMember = await validateUserBelongsToOrg(githubLogin, userOrResponse.id); + if (!isMember) { + return NextResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + const org = await db.sourceControlOrg.findUnique({ + where: { githubLogin }, + select: { defaultWorkspaceId: true }, + }); + if (!org?.defaultWorkspaceId) { + return NextResponse.json( + { error: "Relay unavailable", reason: "NO_DEFAULT_WORKSPACE" }, + { status: 503 }, + ); + } + + const swarmAccess = await getSwarmAccessByWorkspaceId(org.defaultWorkspaceId); + if (!swarmAccess.success) { + return NextResponse.json( + { error: "Relay unavailable", reason: swarmAccess.error.type }, + { status: 503 }, + ); + } + if (!swarmAccess.data.swarmApiKey) { + return NextResponse.json( + { error: "Relay unavailable", reason: "SWARM_API_KEY_MISSING" }, + { status: 503 }, + ); + } + if (!swarmAccess.data.swarmName) { + return NextResponse.json( + { error: "Relay unavailable", reason: "SWARM_NAME_MISSING" }, + { status: 503 }, + ); + } + + const token = signRelayToken( + { + userId: userOrResponse.id, + name: userOrResponse.name, + image: null, + resource: `canvas:${githubLogin}`, + }, + swarmAccess.data.swarmApiKey, + TOKEN_TTL_SECONDS, + ); + + return NextResponse.json({ + token, + url: getRelayUrl(swarmAccess.data.swarmName, swarmAccess.data.swarmUrl), + expiresInSeconds: TOKEN_TTL_SECONDS, + }); + } catch (error) { + console.error("Error issuing canvas relay token:", error); + return NextResponse.json( + { error: "Failed to issue relay token" }, + { status: 500 }, + ); + } +} diff --git a/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx b/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx index 00211e7f99..b3f77cb7de 100644 --- a/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx +++ b/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx @@ -35,7 +35,7 @@ import { CreateServiceCanvasDialog } from "../_components/CreateServiceCanvasDia import { categoryAllowedOnScope, CATEGORY_REGISTRY } from "./canvas-categories"; import { useCanvasChatStore } from "../_state/canvasChatStore"; import { useSession } from "next-auth/react"; -import { useCanvasCollaboration } from "@/hooks/useCanvasCollaboration"; +import { useCanvasCollaborationViaRelay } from "@/hooks/useCanvasCollaborationViaRelay"; import { toast } from "sonner"; import { Spinner } from "@/components/ui/spinner"; import { Button } from "@/components/ui/button"; @@ -737,7 +737,7 @@ export function OrgCanvasBackground({ }); // Real-time canvas presence — cursors, selection halos, conflict flash - const { collaborators } = useCanvasCollaboration({ + const { collaborators } = useCanvasCollaborationViaRelay({ githubLogin, canvasRef: currentRef, userId: session?.user?.id ?? "", diff --git a/src/hooks/useCanvasCollaboration.ts b/src/hooks/useCanvasCollaboration.ts index 327ca4876a..76b12b5bc5 100644 --- a/src/hooks/useCanvasCollaboration.ts +++ b/src/hooks/useCanvasCollaboration.ts @@ -19,7 +19,7 @@ interface CollaboratorState extends Omit`); each cursor carries its + * `canvasRef` so peers only render cursors/selection for the sub-canvas + * they're currently viewing. + */ + +interface UseCanvasCollaborationResult { + collaborators: CanvasCollaboratorInfo[]; +} + +const CLIENT_TTL_MS = 60_000; +const PRUNE_INTERVAL_MS = 15_000; +const CURSOR_THROTTLE_MS = 50; + +function teamAvatarColor(userId: string): string { + const palette = [ + "#5EEAD4", "#38BDF8", "#A78BFA", "#FB923C", "#34D399", "#F472B6", + "#FACC15", "#60A5FA", "#F87171", "#C084FC", "#2DD4BF", "#4ADE80", + ]; + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = (hash * 31 + userId.charCodeAt(i)) | 0; + } + return palette[Math.abs(hash) % palette.length]; +} + +/** One connected peer socket. Keyed by senderId (socket.id). */ +interface PeerState { + userId: string; + name: string; + color: string; + image: string | null; + cursor: { x: number; y: number } | null; + selectedNodeId: string | null; + /** Sub-canvas the cursor/selection is on; only rendered when it matches ours. */ + cursorRef: string; + lastSeenAt: number; +} + +interface RosterEntry { + odinguserId: string; + name: string; + image: string | null; + color: string; + joinedAt: number; + senderId: string; +} + +export function useCanvasCollaborationViaRelay({ + githubLogin, + canvasRef, + userId, + userName, + userImage, + getViewport, + getSvgElement, + containerRef, + selectedNodeId, + enabled = true, +}: UseCanvasCollaborationOptions): UseCanvasCollaborationResult { + const [peers, setPeers] = useState>(new Map()); + const socketRef = useRef(null); + + // Stable refs for use inside socket/pointer callbacks. + const userIdRef = useRef(userId); + userIdRef.current = userId; + const canvasRefRef = useRef(canvasRef); + canvasRefRef.current = canvasRef; + const selectedNodeIdRef = useRef(selectedNodeId); + selectedNodeIdRef.current = selectedNodeId; + const lastCursorRef = useRef<{ x: number; y: number } | null>(null); + + const upsertPeer = useCallback( + (senderId: string, patch: Partial) => { + setPeers((prev) => { + const next = new Map(prev); + const existing: PeerState = next.get(senderId) ?? { + userId: "", + name: "", + color: "#888", + image: null, + cursor: null, + selectedNodeId: null, + cursorRef: "", + lastSeenAt: Date.now(), + }; + next.set(senderId, { ...existing, ...patch, lastSeenAt: Date.now() }); + return next; + }); + }, + [], + ); + + const removePeer = useCallback((senderId: string) => { + setPeers((prev) => { + if (!prev.has(senderId)) return prev; + const next = new Map(prev); + next.delete(senderId); + return next; + }); + }, []); + + // Connect + handle presence events. + useEffect(() => { + if (!enabled || !userId) return; + let cancelled = false; + let socket: Socket | null = null; + + (async () => { + let token: string; + let url: string; + try { + const res = await fetch(`/api/orgs/${githubLogin}/canvas/relay-token`); + if (!res.ok) { + console.warn("[canvas-relay] token fetch failed:", res.status); + return; + } + ({ token, url } = await res.json()); + } catch (err) { + console.error("[canvas-relay] token fetch error:", err); + return; + } + if (cancelled) return; + + socket = io(url, { auth: { token }, transports: ["websocket"] }); + socketRef.current = socket; + + socket.on("room:roster", (data: { collaborators: RosterEntry[] }) => { + for (const c of data.collaborators) { + upsertPeer(c.senderId, { + userId: c.odinguserId, + name: c.name, + color: c.color, + image: c.image, + }); + } + }); + + socket.on("user:join", (data: { user: RosterEntry }) => { + upsertPeer(data.user.senderId, { + userId: data.user.odinguserId, + name: data.user.name, + color: data.user.color, + image: data.user.image, + }); + }); + + socket.on("user:leave", (data: { senderId: string }) => { + removePeer(data.senderId); + }); + + socket.on( + "cursor:update", + (data: { + senderId: string; + userId?: string; + cursor: { x: number; y: number } | null; + color: string; + selectedNodeId: string | null; + canvasRef: string; + username?: string; + }) => { + upsertPeer(data.senderId, { + cursor: data.cursor, + color: data.color, + selectedNodeId: data.selectedNodeId, + cursorRef: data.canvasRef ?? "", + // Carry identity so the self-filter works even if we never saw a + // roster/join for this socket (e.g. a reconnect ghost or another + // tab of the same user). + ...(data.userId ? { userId: data.userId } : {}), + ...(data.username ? { name: data.username } : {}), + }); + }, + ); + })(); + + return () => { + cancelled = true; + socket?.disconnect(); + socketRef.current = null; + setPeers(new Map()); + }; + }, [githubLogin, userId, enabled, upsertPeer, removePeer]); + + // Broadcast cursor via pointermove (throttled), in canvas space. + useEffect(() => { + const el = containerRef.current; + if (!el || !enabled || !userId) return; + let lastFired = 0; + + const onMove = (e: PointerEvent) => { + const now = Date.now(); + if (now - lastFired < CURSOR_THROTTLE_MS) return; + lastFired = now; + + const vp = getViewport(); + const svgEl = getSvgElement(); + const rect = (svgEl ?? el).getBoundingClientRect(); + const canvasX = (e.clientX - rect.left - vp.x) / vp.zoom; + const canvasY = (e.clientY - rect.top - vp.y) / vp.zoom; + lastCursorRef.current = { x: canvasX, y: canvasY }; + + socketRef.current?.emit("cursor:update", { + cursor: { x: canvasX, y: canvasY }, + color: teamAvatarColor(userId), + selectedNodeId: selectedNodeIdRef.current, + canvasRef: canvasRefRef.current, + }); + }; + + el.addEventListener("pointermove", onMove); + return () => el.removeEventListener("pointermove", onMove); + }, [containerRef, getViewport, getSvgElement, userId, enabled]); + + // Broadcast selection changes (piggybacks on cursor:update with last cursor). + const prevSelected = useRef(undefined); + useEffect(() => { + if (!enabled || !userId) return; + if ( + prevSelected.current === undefined && + (selectedNodeId === null || selectedNodeId === undefined) + ) { + prevSelected.current = selectedNodeId ?? null; + return; + } + if (prevSelected.current === (selectedNodeId ?? null)) return; + prevSelected.current = selectedNodeId ?? null; + + socketRef.current?.emit("cursor:update", { + cursor: lastCursorRef.current, + color: teamAvatarColor(userId), + selectedNodeId: selectedNodeId ?? null, + canvasRef: canvasRefRef.current, + }); + }, [selectedNodeId, userId, enabled]); + + // Client-side TTL prune. + useEffect(() => { + if (!enabled) return; + const interval = setInterval(() => { + const cutoff = Date.now() - CLIENT_TTL_MS; + setPeers((prev) => { + let changed = false; + const next = new Map(prev); + for (const [id, entry] of prev) { + if (entry.lastSeenAt < cutoff) { + next.delete(id); + changed = true; + } + } + return changed ? next : prev; + }); + }, PRUNE_INTERVAL_MS); + return () => clearInterval(interval); + }, [enabled]); + + // Derive collaborators (self-filtered; cursor/selection only for THIS ref). + const collaborators: CanvasCollaboratorInfo[] = Array.from(peers.values()) + .filter((p) => p.userId !== userId) + .map((p) => ({ + id: p.userId || "unknown", + name: p.name, + color: p.color, + image: p.image, + cursor: p.cursorRef === canvasRef ? p.cursor : null, + selectedNodeId: + p.cursorRef === canvasRef ? p.selectedNodeId ?? undefined : undefined, + })); + + return { collaborators }; +}