Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.ivory.finance/llms.txt

Use this file to discover all available pages before exploring further.

Architecture

Real-time collaboration is powered by Hocuspocus — a WebSocket server built on top of Yjs. The stack:
Browser (Tiptap + yjs + @hocuspocus/provider)
    │  WebSocket wss://collab.ivory.finance

Hocuspocus Server  (Node.js — ghcr.io/vennroad/ivory-hocuspocus)
    │  JWT verification + ACL check
    ├─► POST /v1/editor/hocuspocus/auth

    │  Load/save Yjs CRDT binary
    ├─► SELECT yjs_state FROM documents WHERE id=$1
    └─► PUT /v1/editor/documents/{doc_id}/yjs
Each document has a room identified by its UUID. Multiple clients connect to the same room and Hocuspocus merges their edits using the Yjs CRDT algorithm.

1. Install dependencies

npm install @hocuspocus/provider yjs @tiptap/core \
  @tiptap/react @tiptap/starter-kit \
  @tiptap/extension-collaboration \
  @tiptap/extension-collaboration-cursor

2. Connect from the browser

Use the document UUID as the Hocuspocus room name. Pass your Ivory JWT as the token — the server calls the auth webhook to verify it before admitting the connection.
import { HocuspocusProvider } from "@hocuspocus/provider";
import * as Y from "yjs";

const ydoc = new Y.Doc();

const provider = new HocuspocusProvider({
  url: "wss://collab.ivory.finance",
  name: documentId,           // the document UUID from GET /v1/editor/documents
  document: ydoc,
  token: IVORY_JWT,           // Ivory Finance JWT (NOT the API key)
  onAuthenticated: () => {
    console.log("Connected and authenticated");
  },
  onAuthenticationFailed: ({ reason }) => {
    console.error("Auth failed:", reason);
  },
  onDisconnect: () => {
    console.log("Disconnected — will auto-reconnect");
  },
});

3. Bind to Tiptap editor

import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";

export function IvoryEditor({ docId, jwt, currentUser }) {
  const ydoc = useMemo(() => new Y.Doc(), []);

  const provider = useMemo(
    () =>
      new HocuspocusProvider({
        url: "wss://collab.ivory.finance",
        name: docId,
        document: ydoc,
        token: jwt,
      }),
    [docId, jwt]
  );

  const editor = useEditor({
    extensions: [
      StarterKit.configure({ history: false }), // history managed by Yjs
      Collaboration.configure({ document: ydoc }),
      CollaborationCursor.configure({
        provider,
        user: {
          name: currentUser.name,
          color: "#0F766E",   // teal — pick a colour per user
        },
      }),
    ],
  });

  return <EditorContent editor={editor} />;
}

4. Sync doc_ast back to REST API

The Yjs CRDT is the source of truth for real-time edits. The REST doc_ast field must be kept in sync for the AI to see the latest content. Trigger the PATCH on blur, explicit save, or every 30 seconds:
// Sync on blur
editor.on("blur", async () => {
  await fetch(`https://api.ivory.finance/v1/editor/documents/${docId}`, {
    method: "PATCH",
    headers: {
      "X-API-Key": IVORY_API_KEY,
      "Authorization": `Bearer ${IVORY_JWT}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      doc_ast: editor.getJSON(),
      updated_by_type: "user",
      updated_by_id: currentUserId,
    }),
  });
});

// Sync every 30s while editor is active
const syncInterval = setInterval(async () => {
  if (editor.isFocused) {
    await fetch(`https://api.ivory.finance/v1/editor/documents/${docId}`, {
      method: "PATCH",
      headers: {
        "X-API-Key": IVORY_API_KEY,
        "Authorization": `Bearer ${IVORY_JWT}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ doc_ast: editor.getJSON() }),
    });
  }
}, 30_000);

// Clean up
return () => clearInterval(syncInterval);
User edits via Tiptap ──► Yjs CRDT ──► Hocuspocus ──► yjs_state in Postgres
                                                        (real-time, automatic)

User blur / save ──► editor.getJSON() ──► PATCH /v1/editor/documents/{id}
                                              ──► doc_ast in Postgres
                                              ──► OpenSearch re-index (async)

5. Read-only mode

Users with viewer role connect successfully but with read_only: true. Disable editing accordingly:
// Option A: check my_role before connecting
const doc = await fetch(`https://api.ivory.finance/v1/editor/documents/${docId}`, {
  headers: { "X-API-Key": IVORY_API_KEY, "Authorization": `Bearer ${IVORY_JWT}` },
}).then(r => r.json());

if (doc.my_role === "viewer") {
  editor.setEditable(false);
}

// Option B: respond to Hocuspocus auth result
provider.on("authenticated", () => {
  if (!provider.isWriteable) {
    editor.setEditable(false);
  }
});

Auth webhook (internal)

On every new WebSocket connection, Hocuspocus calls the auth webhook before allowing access. You don’t call this from the browser — it’s invoked by the Hocuspocus server process.
curl -X POST https://api.ivory.finance/v1/editor/hocuspocus/auth \
  -H "Content-Type: application/json" \
  -d '{
    "token": "<jwt>",
    "documentName": "doc_01JK4...",
    "requestParameters": {}
  }'
The webhook:
  1. Verifies the JWT signature
  2. Checks the user’s effective role on the document
  3. Returns read_only: true for viewer role, false for editor/owner

Yjs state persistence (internal)

Hocuspocus automatically saves the Yjs binary after each document update. Do not call this from your frontend — it is internal, authenticated via a shared secret.
# Internal — called by Hocuspocus server, not the browser
curl -X PUT "https://api.ivory.finance/v1/editor/documents/doc_01JK4.../yjs" \
  -H "X-Hocuspocus-Secret: $HOCUSPOCUS_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "yjs_state": "<base64-encoded Yjs binary>",
    "updated_by_type": "user",
    "updated_by_id": "user_abc123"
  }'
yjs_state vs doc_ast — two parallel representations:
  • yjs_state — Yjs CRDT binary. Source of truth for the real-time editor. Hocuspocus manages it exclusively.
  • doc_ast — Tiptap JSON. Source of truth for agents and REST consumers. Synced via PATCH /v1/editor/documents/{id}.

Environment variables (Hocuspocus server)

VariableDescription
DATABASE_URLPostgreSQL connection string (for Yjs state load/save)
IVORY_API_URLInternal API URL for the auth webhook (e.g. http://ivory-api.default.svc.cluster.local:8000)
HOCUSPOCUS_SECRETShared secret for PUT /v1/editor/documents/{id}/yjs
PORTWebSocket listen port (default: 1234)