Stood up a self-hosted, real-time collaborative tldraw whiteboard (“Miro-type” board) on telep-mainframe with PDF annotation, plus a TV kiosk “board mode” on the 4K HDMI that toggles with the camera wall. A single Bun process serves both the built tldraw client and the WebSocket sync/asset backend on port 8790.

For Agents

App dir: /home/levander/tldraw-board. One Bun process serves the built Vite/React tldraw client AND the WebSocket sync + asset endpoints on port 8790 (binds 0.0.0.0). systemd: tldraw-board.service (runs as levander, Restart=always, enabled). URLs: tailnet http://telep-mainframe.taild4189d.ts.net:8447/ (via tailscale serve --http 8447 → 8790); LAN http://192.168.1.123:8790/. Rooms: /r/<roomId> or ?room=<id>; default room main at /. Data: data/rooms/<roomId>.json (persisted snapshots) + data/assets/ (uploaded images + PDF page PNGs). TV kiosk: /usr/local/bin/tv wrapper — tv board / tv cams / tv status. board-kiosk.service (root, DISPLAY=:0) is NOT enabled at boot — camwall is the default; the two are mutually exclusive (Conflicts= both directions). ⚠️ tldraw 5.2.5 renders a BLANK canvas over HTTPS without a paid license — that is why every URL above (and the kiosk) is plain HTTP, not HTTPS.

The board app

  • Dir: /home/levander/tldraw-board.
  • Single Bun process serves the built Vite/React tldraw client AND the WebSocket sync + asset endpoints on port 8790 (binds 0.0.0.0, so LAN + tailnet both reach it).
  • systemd unit: tldraw-board.service — runs as levander, Restart=always, enabled.
  • Persistence:
    • data/rooms/<roomId>.json — persisted room snapshots.
    • data/assets/ — uploaded images + PDF page PNGs.

URLs & rooms

PathAddress
Tailnethttp://telep-mainframe.taild4189d.ts.net:8447/ (via tailscale serve --http 8447 → 8790)
LANhttp://192.168.1.123:8790/
Room by path/r/<roomId>
Room by query?room=<id>
Default room main/

Stack detail

  • tldraw 5.2.5.
  • Client: uses useSync({uri, assets}) from @tldraw/sync.
  • Server: keeps one TLSocketRoom (@tldraw/sync-core) per room over Bun’s native WebSocket (handleSocketConnect / handleSocketMessage / handleSocketClose).
  • Snapshot persistence: JSON — initialSnapshot on room load + a debounced onDataChangegetCurrentSnapshot() writeback to data/rooms/<roomId>.json.
  • Assets: served via PUT/GET /uploads/:id — shared uploads, not data-URLs embedded in the document (keeps snapshots small; images/PDF pages live in data/assets/).
  • PDF import: pdf.js renders each page → PNG → uploaded as an asset → placed as locked, stacked image shapes (the tldraw pdf-editor example approach), so you annotate on top of the pages.

CRITICAL GOTCHA — tldraw 5.2.5 renders a BLANK canvas over HTTPS without a paid license

tldraw’s LicenseManager.isDevelopment is only true when protocol is NOT https, OR host is loopback, OR NODE_ENV != production. Over plain HTTPS with a non-loopback host and no purchased license, the watermark/gate logic leaves the canvas blank.

Fix used: serve the tailnet endpoint over plain HTTPtailscale serve --http 8447. This is still WireGuard-encrypted over the tailnet, so it is not a plaintext-on-the-wire regression. It leaves a small “get a license” watermark link.

Proper HTTPS needs a purchased tldraw license (pass the licenseKey prop; contact sales@tldraw.com).

This is the reason the kiosk and tailnet URLs are HTTP, not HTTPS.

TV kiosk “board mode”

The 4K HDMI TV can show either the camera wall (default) or the tldraw board — never both. Toggled with a small wrapper; the two systemd units Conflicts= each other in both directions.

Files

FileRole
/usr/local/bin/tvWrapper: tv board (stop camwall.service, start board-kiosk.service), tv cams (reverse), tv status. board/cams need sudo (systemctl) — sudo -n works passwordless on this box.
/usr/local/bin/board-kiosk.shStarts matchbox WM, then chromium --kiosk --app=http://192.168.1.123:8790/r/main on DISPLAY=:0. Flags include --no-sandbox (mandatory: chromium runs as root), --force-device-scale-factor=2 (legible on the 4K TV), --user-data-dir=/var/lib/board-kiosk.
/etc/systemd/system/board-kiosk.serviceroot, Environment=DISPLAY=:0, Restart=always, Requires/After=camwall-x.service, Conflicts=camwall.service. NOT enabled at boot (camwall is the default).
/etc/systemd/system/camwall.service.d/conflict.confDrop-in adding Conflicts=board-kiosk.servicemutual exclusion both directions.

Architecture facts learned (correct any stale notes)

These reuse and confirm the camwall-x vs camwall split first documented in 2026-08-04-telep-mainframe-airplay-receiver-uxplay.

The X server is owned by camwall-x.service, NOT camwall.service

On telep-mainframe the Xorg :0 server is owned by a separate camwall-x.service (xinit, -nolisten tcp). camwall.service is ONLY the mpv grid client. So stopping camwall does NOT kill X — the kiosk (and uxplay) reuse the already-running :0 server. Root connects to :0 with just DISPLAY=:0, no XAUTHORITY.

A kiosk that stops camwall must launch its OWN matchbox WM

matchbox is the WM that fullscreens kiosk windows, and it is a child of camwall’s session. A kiosk that stops camwall.service therefore tears down that matchbox — so board-kiosk.sh must launch its own matchbox or windows won’t fullscreen. Before this fix, chromium came up as a floating half-window (not fullscreen) until board-kiosk started its own WM.

Tailscale serve

Existing tailscale serve entries were preserved — 8443, 8445, 8446, and root→8971. Only 8447 was added (--http 8447 → 8790), deliberately HTTP for the tldraw-HTTPS gotcha above.

2026-08-31 — named identity, Mermaid import, and the loopback security fix

The board gained named collaborator identity and editable Mermaid import, and a pre-existing exposure was closed. The tailnet front door is now board.taild4189d.ts.net via a board-tsnet tsnet proxy (dirs /home/levander/board-tsnet + /home/levander/tldraw-board).

SECURITY FIX — the board was listening on *:8790 (LAN + tailnet reachable, identity-spoofable)

As first documented above, tldraw-board bound 0.0.0.0:8790 — directly reachable from the LAN and the tailnet, bypassing the identity proxy entirely, so anyone on either could set Remote-User themselves. Now bound loopback-only (HOST=127.0.0.1 via a systemd drop-in). Identity is resolved in-process by board-tsnet: it is a tsnet.Server that terminates the tailnet TLS itself, so r.RemoteAddr is the real (unforgeable) peer, and s.LocalClient().WhoIs(...) resolves it with no X-Forwarded-For parsing (unlike the Drive’s tsauth-proxy, which must trust XFF because it sits behind another proxy). Resolution order mirrors tsauth-proxy: LoginNameTS_TAG_USERS tag→user map (same /etc/board-identity.env) → guest.

Loopback alone was not sufficient: Caddy also proxies http://whiteboard.telep.lan127.0.0.1:8790 and both connect from loopback, so a shared secret (X-Board-Identity-Secret from /etc/board-identity.env) gates the Remote-User header — the server honours it only when the secret matches, and strips all four client-supplied identity headers first. Fails closed (no secret → everyone is a guest). UTF-8 names (“András Léderer”) are percent-encoded on the Go side because HTTP headers are ISO-8859-1.

Named presence: useSync({ users }) feeds the resolved name into tldraw multiplayer presence (each user a deterministic colour); the name also persists into the room snapshot for attribution. Unresolvable users still get in as a stable per-browser Guest.

Mermaid import (@tldraw/mermaid@5.2.5, exposed like PdfImport — a SharePanel button + paste handler): flowchart / sequenceDiagram / stateDiagram-v2 / mindmap import as real editable tldraw shapes with bound connectors; everything else (pie, gantt, class, ER, journey) falls back to a rendered SVG image via onUnsupportedDiagram. That four-type boundary is the library’s own type union, not a local limit.

These board changes are UNCOMMITTED, pending owner review

Neither /home/levander/tldraw-board nor /home/levander/board-tsnet is a git repository, so there is no diff to review — every modified file has a .bak-identity sibling and a full tarball backup exists (/home/levander/backups/tldraw-board-20260831-052642.tar.gz). The changes are live in the working tree but not persisted to any repo; treat them as pending until the owner reviews.

The tag:telep-user mapping is currently dead config

TS_TAG_USERS=tag:telep-user=falusimarko0240@gmail.com is configured, but tailnet ACLs prevent every tagged device from reaching board — only user-owned devices can, and they all belong to one login. If a tagged collaborator is meant to use the board, the ACL must grant that tag access to board first. The kiosk URL was also repointed off the now-removed LAN IP to http://127.0.0.1:8790/r/main.