As a cybersecurity student, I use multiple digital platforms, and each one needs careful attention to keep it secure.
My workflow often dances around three distinct silos: my code repositories, on Gitlab and often Gitea, my personal notes in Obsidian and Notion, and my task lists.
When I was working on a complex project, I kept switching between a terminal to check a Git issue, a markdown file to read the spec, and a kanban board to move a ticket. It was a bit distracting having to jump around like that.
I wanted a single interface, a War Room, where I could visualize all of this side-by-side on an infinite canvas.
So, I decided to build it. The project, Ideon, introduces an awesome tool that combines an infinite spatial canvas with real-time collaboration and Git integration, enhancing how developers visualize and manage their workflows.
This article explores the technical challenges of creating a “code-aware” collaborative whiteboard, particularly the integration of imperative canvas libraries with distributed state management.
The Core Challenge: Syncing an Infinite Canvas
The most interesting technical hurdle was integrating ReactFlow (an imperative library for node-based UIs) with Yjs (a CRDT library for distributed state).
The "Double Truth" Problem
ReactFlow maintains its own internal state for node positions, viewports, and interactions. Yjs, on the other hand, is designed to be the "source of truth" for distributed data. When you have two sources of truth, you inevitably run into conflict.
If a user drags a node, ReactFlow updates its local state immediately for performance (60fps). If I naively broadcast every single onNodeDrag event to Yjs, I would flood the WebSocket connection and kill the performance for every other connected client.
To solve this, I had to decouple the "visual" state from the "persisted" state.
I implemented a custom hook, useProjectCanvasRealtime, that acts as the bridge. Instead of syncing every pixel of movement, it relies on the concept of "awareness" for ephemeral data (like cursors) and throttled updates for persistent data (like node positions).
Here is a simplified look at how I handle user presence without spamming the network:
// useProjectCanvasRealtime.ts
export const useProjectCanvasRealtime = (
awareness: Awareness | null,
currentUser: UserPresence | null,
shareCursor: boolean = true,
) => {
const { screenToFlowPosition } = useReactFlow();
// Throttled pointer move handler
const onPointerMove = useCallback(
(event: React.PointerEvent) => {
if (!shareCursor) return;
// Crucial: Convert screen coordinates to Flow (canvas) coordinates
const cursor = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// Update local state, which Yjs propagates efficiently
updateMyPresence({ cursor });
},
[screenToFlowPosition, updateMyPresence, shareCursor],
);
// ...
};
The key insight here is screenToFlowPosition. In an infinite canvas, sharing raw (x, y) screen coordinates is useless because every user has a different viewport (zoom level, pan position). You must normalize coordinates to the "world" space before broadcasting them.
Security: The SSRF Nightmare of Self-Hosted Tools
Since my target audience includes self-hosters (myself included), I knew that Ideon would often be deployed inside private networks (e.g., a home lab or a corporate VPN).
The feature I wanted most was "Git Blocks": widgets that you drop on the canvas to show live stats from a repository.
If I implemented this naively by having the frontend fetch data directly, I would hit CORS issues. If I had the backend fetch data based on a user-provided URL, I would open a massive security hole: Server-Side Request Forgery (SSRF).
An attacker could theoretically drop a Git Block with the URL http://169.254.169.254/latest/meta-data/ (AWS metadata) or http://localhost:5432 (internal database) and read the response. To prevent this, I built a dedicated proxy that enforces strict validation before making any request.
The Defense Strategy
1. Private IP Blocking: The proxy resolves the hostname and checks if it resolves to a private IP range (10.0.0.0/8, 192.168.0.0/16, etc.), unless explicitly whitelisted.
2. Protocol Enforcement: Only HTTP and HTTPS are allowed, no file:// or gopher:// wrapper tricks.
3. Token Encryption: User tokens for Gitea/GitLab are stored encrypted in the database and only decrypted ephemerally within the proxy scope. They are never sent to the client.
Here is the logic for the proxy (simplified):
// api/git/stats/route.ts
// 1. Validate the URL format
const u = new URL(url.startsWith("http") ? url : `https://${url}`);
const host = u.host;
// 2. Retrieve and decrypt the token server-side
const userTokens = await db.selectFrom("userGitTokens")...
// ... decryption logic ...
// 3. Prevent SSRF
if (isPrivateIp(host) && !process.env.ALLOW_LOCAL_NETWORKS) {
return NextResponse.json({ error: "Access to local network denied" }, { status: 403 });
}
// 4. Fetch the data safely
const result = await getRepoStats(url, token);
This ensures that while the tool is "connected," it respects the boundaries of the network it runs on.
Handling "Canvas Version Control"
Another challenge was versioning. In a text document, Ctrl+Z is straightforward. In a collaborative spatial canvas, it’s a nightmare. If User A moves a node and User B deletes an edge, what does "Undo" mean?
I leveraged Yjs's built-in UndoManager, but I had to scope it carefully.
I realized that users don't want to undo other people's actions. They want to undo their own. Yjs supports this by tracking the origin of a transaction.
However, I wanted something more: Decision History. I wanted to be able to "snapshot" the entire board state at a specific point in time, like a Git commit for the canvas.
I implemented a DecisionHistory component that serializes the Yjs document state into a JSON snapshot when the user chooses to "commit" a version. This allows teams to see why the board looked a certain way two weeks ago, effectively bringing Git semantics to the whiteboard itself.
Lessons Learned Building This
1. The "Network-First" Mindset
When building local-first or self-hosted apps, you can't assume the network is reliable. Using y-indexeddb alongside y-websocket was non-negotiable. It allows the app to work perfectly offline and sync up when the connection returns. It changes the UX from "loading spinners" to "instant interaction."
2. Don't Reinvent the Wheel (Too Much)
I first tried to build my own WebSocket protocol, but it turned into a disaster filled with race conditions and lost updates. Switching to Yjs and its ecosystem, including y-websocket and y-indexeddb, solved 90% of the synchronization headaches. This allowed me to focus more on product features, such as the Git integration.
3. Security is a Feature, Not a Patch
Implementing SSRF protection changed how I architected the API. It forced me to route external data fetching through a controlled bottleneck rather than letting the frontend run wild.
Conclusion
What began as a personal endeavor to organize my digital chaos evolved into Ideon, a tool that not only streamlined my workflow but also taught me invaluable lessons about collaboration, synchronization, and security.
Building it taught me that the gap between "it works on localhost" and "it works for a team" is filled with interesting problems like CRDT conflict resolution, coordinate mapping, and network security.
If you’re building a collaborative tool, I recommend looking into Yjs. The learning curve is steep, but Yjs gives you the power to build robust, conflict-free applications.
For those interested, the repository is open source. I’m constantly pushing updates, so you can see exactly how the sausage is made, bugs and all.
Happy coding.
Repository: https://github.com/3xpyth0n/ideon
Documentation: https://www.theideon.com/docs
