Skip to content

Latest commit

 

History

History
794 lines (661 loc) · 36.9 KB

File metadata and controls

794 lines (661 loc) · 36.9 KB

ZenNotes Web Architecture

Target: turn ZenNotes into a progressive web app (PWA) that can also be self-hosted on a home server and driven entirely from a browser, without losing what makes ZenNotes ZenNotes — keyboard-first editing, vim motions, plain Markdown files on disk, and first-party MCP access.

This is a design doc, not an implementation plan. It describes the architecture, the deployment modes we want to support, and the phased porting path from the current Electron build.


1. Goals and non-goals

Goals

  • A browser-accessible ZenNotes that keeps full parity with the desktop build for editing, navigation, search, tasks, and rendering.
  • Installable as a PWA (add-to-home-screen, standalone window, offline).
  • Self-hostable on a home server via Docker, one volume, one port, no database setup required.
  • Vault is still plain Markdown files on a filesystem. No migration, no lock-in, no proprietary store.
  • MCP server keeps working against the same vault, so Claude Desktop / Claude Code / Codex etc. still read and write user notes safely.
  • Same keyboard model in the browser as on desktop: vim mode, leader flows, command palette, which-key hints, buffer switching.

Non-goals (for v1)

  • Real-time multi-user collaboration (CRDTs, presence cursors).
  • A hosted SaaS offering run by the ZenNotes project. (We design so it is possible later; we don't ship it now.)
  • Mobile-first UI. Mobile support is nice to have, but the primary web target is a desktop browser on a laptop or workstation.
  • Replacing the Electron build. The desktop app stays, and users can run both against the same vault.

Constraints that shape the design

  • The renderer (src/renderer/) is a full React SPA with Zustand, CodeMirror 6, and the unified markdown pipeline. All of that is browser-compatible already. The porting work is almost entirely in the main/preload layer.
  • The backend is a new Go service, not a reused Node/Electron module. src/main/vault.ts is the reference behavior — the Go service implements the same operations, validated against the same test fixtures. This trades "share one vault module" for the things Go buys us on a home server: single static binary, cold-start in milliseconds, tiny memory footprint, trivial multi-arch cross-compile, and a FROM scratch container image.
  • MCP is part of the product, not an accessory. Any deployment story must keep the MCP server reachable against the same vault.

2. Deployment modes

ZenNotes Web supports four deployment modes. They share the same client bundle and, for three of them, the same server binary.

Mode A — Self-hosted home server (primary target)

A single Docker container (or single binary) runs on a home NAS, Mac mini, Raspberry Pi, or VPS. The user mounts a vault directory into the container and points a browser at https://notes.home.lan (or a Tailscale/Cloudflare Tunnel hostname). This is the deployment the "users who want to run this on their home server" request maps to.

  • One container, one volume, one port.
  • Single-user auth out of the box (bearer token in a cookie).
  • Reverse proxy (Caddy) handles TLS so PWA features work.
  • Optional sidecar MCP container shares the same vault volume.

Mode B — Pure local PWA (no server)

For users who want zero backend and only their own browser, we ship a "local vault" mode that uses the browser's File System Access API. The user clicks "Open vault", picks a directory, and the PWA reads and writes Markdown files directly through the browser.

  • Chromium-only, desktop-only.
  • No network traffic, no install, no account.
  • Good entry-level mode and a useful fallback if the home server is down.
  • MCP is not available in this mode (no Node process on the box).

Mode C — Shared home server (multi-user, optional)

Same container as Mode A, but with per-user vault dirs and session auth (passkeys or argon2-hashed passwords). Useful for families or small teams running a single home server. Off by default.

Mode D — Hosted SaaS

The same Go binary, run by us (or anyone) as a managed multi-tenant service. Users sign up with email or passkey, pay via Stripe, and get a vault at https://app.zennotes.io without running anything themselves. This serves people who want the product but won't self-host.

  • Same server binary as Mode A, with --mode=saas plus a control plane (see §11). No second codebase.
  • Per-tenant vault directories under a single storage root.
  • Managed auth (email + passkey + OAuth), billing, email, backups.
  • MCP access remains available for paid tenants via a per-tenant scoped HTTPS tunnel.
  • Shipping Mode D is in scope for this phase, not deferred. Detailed architecture in §11.

3. High-level architecture

┌──────────────────────────┐         ┌──────────────────────────┐
│        Browser           │         │     Home server host     │
│                          │         │                          │
│  ┌────────────────────┐  │  HTTPS  │  ┌────────────────────┐  │
│  │   ZenNotes PWA     │◄─┼─────────┼─►│  zennotes-server   │  │
│  │  (React renderer)  │  │   WS    │  │  (Go, static bin)  │  │
│  │                    │  │         │  │                    │  │
│  │  CodeMirror 6      │  │         │  │  chi + net/http    │  │
│  │  Zustand store     │  │         │  │  fsnotify watcher  │  │
│  │  Service worker    │  │         │  │  SQLite FTS index  │  │
│  │  IndexedDB cache   │  │         │  │  embedded PWA      │  │
│  └────────────────────┘  │         │  └─────────┬──────────┘  │
│                          │         │            │             │
└──────────────────────────┘         │  ┌─────────▼──────────┐  │
                                     │  │  Filesystem vault  │  │
                                     │  │  /vault/           │  │
                                     │  │    inbox/          │  │
                                     │  │    quick/          │  │
                                     │  │    archive/        │  │
                                     │  │    trash/          │  │
                                     │  │    attachements/   │  │
                                     │  └─────────▲──────────┘  │
                                     │            │             │
                                     │  ┌─────────┴──────────┐  │
                                     │  │  zennotes-mcp      │  │
                                     │  │  (sidecar)         │  │
                                     │  └────────────────────┘  │
                                     └──────────────────────────┘

Three moving pieces:

  1. Client — the existing renderer, unchanged in structure. All the Electron-specific code paths are hidden behind a single interface (VaultBridge, see §6). The client never talks to Node APIs directly; it talks to a bridge.
  2. Server — a Go binary (zennotes-server) that exposes the same vault operations the Electron main process implements today, but over HTTP and WebSocket instead of IPC. Ships as a single static executable; Docker image is FROM scratch.
  3. MCP sidecar — the existing standalone MCP server from src/mcp/ (Node), reachable against the same vault. Shipped as a separate container so it can be disabled independently, and so we can keep using the upstream TS MCP SDK without re-porting it to Go.

4. What ports, what must be replaced

Maps cleanly against src/main/ (replace) and src/renderer/ (keep).

Area Today (Electron, TS) Web version
Window lifecycle BrowserWindow, app menu Browser handles it
Preload IPC bridge src/preload/index.ts VaultBridge + fetch client
Vault I/O src/main/vault.ts (Node fs) Go port in server/internal/vault, behind HTTP handlers
File watcher chokidar (Node) fsnotify (Go), piped over WS
Asset protocol zen-asset:// via protocol.handle GET /api/assets/*
Native file picker dialog.showOpenDialog File System Access API (Mode B) or server-side config screen (Mode A)
Auto-updater electron-updater + GitHub releases Service worker update + container image tag
Zoom / window state BrowserWindow.setZoomFactor Browser zoom, CSS zoom if needed
Global shortcuts globalShortcut.register Only in-tab; keep in-app bindings
Deep links zen:// custom protocol https://.../open?path=...
Notifications Node Notification Web Notifications API
Menus Native Menu In-app command palette (already exists)
MCP server Bundled Node, spawned over stdio Separate container (still Node), same code

Everything under src/renderer/ is already browser-safe: CodeMirror 6, unified/remark/rehype, KaTeX, Mermaid, JsxGraph, Tailwind, Zustand, DOMPurify, Fuse.js. The port does not touch any of that.


5. Server

5.1 Stack

  • Language: Go 1.22+. Chosen for static binaries, fast startup, low idle memory (critical on a Raspberry Pi or small VPS), trivial cross-compilation for amd64/arm64, and a FROM scratch Docker image.
  • Router: go-chi/chi on top of net/http. Idiomatic, stable, composable middleware, no framework lock-in. We deliberately avoid non-stdlib-compatible frameworks (Fiber/fasthttp) so every standard middleware and observability tool works.
  • WebSocket: coder/websocket (formerly nhooyr.io/websocket). Context-aware, modern API, cleaner than gorilla/websocket. WS is preferred over SSE because the client already needs bidirectional channels for future features (collaboration, command streaming).
  • Filesystem: os / io/fs / path/filepath (stdlib) for vault I/O, fsnotify/fsnotify for watching.
  • Search index: SQLite with FTS5 via modernc.org/sqlite (pure-Go, no CGO). Rebuilt on startup, maintained incrementally by the watcher. Stored at /vault/.zennotes/index.db. For small vaults we skip it and fall back to an in-memory index.
  • Markdown parsing (server-side): yuin/goldmark with GFM, YAML frontmatter, and wikilink extensions. Used only for extracting tags, tasks, and backlinks into the FTS index — rendering still happens in the browser via the existing unified pipeline, so the Go parser is a one-way metadata extractor, not a renderer.
  • Config: single file at /vault/.zennotes/server.json plus env vars (ZENNOTES_VAULT_PATH, ZENNOTES_BIND, ZENNOTES_AUTH_TOKEN).
  • Static assets: PWA bundle embedded via go:embed at build time, so the server binary is literally the whole app — no external public/ directory, no separate CDN step.
  • No CGO: modernc.org/sqlite is pure Go, fsnotify uses syscalls, coder/websocket is pure Go. The binary is statically linked (CGO_ENABLED=0), which is what makes the FROM scratch image work.

5.2 Vault logic: Go as the new source of truth

The current src/main/vault.ts is the behavioral reference. The Go server implements the same operations — safe path resolution, soft delete to trash/, move semantics across folders, duplicate, rename, archive/unarchive, asset indexing, tag/task/backlink extraction — in idiomatic Go under server/internal/vault/.

We accept a controlled duplication:

  • Shared contract: the HTTP surface (§5.3) is the single source of truth for what a "vault operation" means. An OpenAPI spec (api/openapi.yaml) is the machine-readable contract.
  • Shared test fixtures: the existing src/main/vault.test.ts scenarios are mirrored as Go tests using the same input vaults and expected outputs. This is the anti-drift mechanism.
  • Code-gen clients: the client's HttpBridge (TypeScript) is generated from the OpenAPI spec so request/response types cannot drift from the Go handlers.

Proposed server layout:

server/                   ← Go module, separate from TS source
  cmd/
    zennotes-server/
      main.go             ← entrypoint, flag parsing, embed directive
  internal/
    vault/                ← filesystem ops (CRUD, move, trash…)
      ops.go
      ops_test.go         ← mirrors vault.test.ts fixtures
      safepath.go         ← path-traversal guard
    watcher/              ← fsnotify → event channel
      watcher.go
    index/                ← SQLite FTS5 + in-memory fallback
      sqlite.go
      memory.go
      parse.go            ← goldmark extraction (tags/tasks/backlinks)
    http/
      router.go           ← chi routes
      notes.go
      search.go
      assets.go
      watch.go            ← WS handler
      auth.go
      middleware.go
    config/
      config.go
  web/                    ← built PWA, embedded
    embed.go              ← //go:embed dist
    dist/                 ← output of `npm run build:web`
  api/
    openapi.yaml          ← single source of truth for API shape
  Dockerfile
  go.mod

Long-term direction (not v1): once the Go server is proven, the Electron app moves to bundling the Go binary as a child process and the renderer talks to http://127.0.0.1:<port> via the same HttpBridge. That collapses the two vault implementations into one and eliminates the drift concern entirely. We note it as a direction here and return to it in §12.

5.3 HTTP API

REST, JSON bodies, all under /api. Paths relative to the vault root.

Method Path Purpose
GET /api/tree Full folder/note tree (cached, etag'd)
GET /api/notes/* Read a note (Markdown body + frontmatter)
PUT /api/notes/* Write a note. Optional If-Match header
POST /api/notes Create / rename / move / duplicate
DELETE /api/notes/* Trash a note (soft)
POST /api/notes/*/restore Restore from trash
POST /api/notes/*/archive Move to archive
POST /api/notes/*/unarchive Move out of archive
GET /api/search?q=&scope= Full-text search (FTS5 or ripgrep fallback)
GET /api/tags All tags with counts
GET /api/tags/:tag Notes matching a tag
GET /api/tasks Aggregated task list
GET /api/backlinks/* Backlinks for a note
GET /api/assets/* Binary asset (range requests)
POST /api/assets Upload asset
WS /api/watch Stream of {type, path, mtime} events
GET /api/config Client-visible config
POST /api/config Update client-visible config
GET /api/healthz Liveness
GET /api/version Server version + build info

All write endpoints accept an If-Match header carrying the last known SHA of the note body. On mismatch the server returns 409 with both versions and the client shows a merge UI. This gives us optimistic concurrency without CRDTs. For the single-user case, conflicts are rare (only across devices or with external edits), so the simple "keep mine / keep theirs / open diff" UI is enough.

5.3 HTTP API

REST, JSON bodies, all under /api. Paths relative to the vault root.

| Method | Path | Purpose | | ------ | ------------------------ | ----------------------------------------- | -------- | ---------------- | | GET | /api/tree | Full folder/note tree (cached, etag'd) | | GET | /api/notes/* | Read a note (Markdown body + frontmatter) | | PUT | /api/notes/* | Write a note. Optional If-Match header | | POST | /api/notes | Create / rename / move / duplicate | | DELETE | /api/notes/* | Trash a note (soft) | | POST | /api/notes/*/restore | Restore from trash | | POST | /api/notes/*/archive | Move to archive | | POST | /api/notes/*/unarchive | Move out of archive | | GET | /api/search?q=&scope= | Full-text search (FTS or ripgrep/fzf) | | GET | /api/tags | All tags with counts | | GET | /api/tags/:tag | Notes matching a tag | | GET | /api/tasks | Aggregated task list | | GET | /api/backlinks/* | Backlinks for a note | | GET | /api/assets/* | Binary asset (range requests) | | POST | /api/assets | Upload asset | | WS | /api/watch | {type: 'add' | 'change' | 'unlink', path} | | GET | /api/config | Client-visible config | | POST | /api/config | Update client-visible config | | GET | /api/healthz | Liveness |

All write endpoints accept an If-Match header carrying the last known SHA of the note body. On mismatch the server returns 409 with both versions and the client shows a merge UI. This gives us optimistic concurrency without CRDTs. For the single-user case, conflicts are rare (only across devices or with external edits), so the simple "keep mine / keep theirs / open diff" UI is enough.

5.4 Watcher channel

/api/watch is a WebSocket. Server emits:

{ "type": "change", "path": "inbox/my-note.md", "mtime": 1700000000000 }
{ "type": "add",    "path": "inbox/new.md",     "mtime": 1700000001000 }
{ "type": "unlink", "path": "inbox/old.md" }

Clients debounce and reconcile against their local cache, same way the renderer already reacts to chokidar events through IPC today. The transport changes and the Go fsnotify driver replaces chokidar; the client-side handling doesn't.

5.5 Auth

  • Mode A (single user): On first boot the server generates a long random token (crypto/rand) and prints it to logs (and to a file at /vault/.zennotes/token). The user pastes it into the login screen; the server sets a long-lived HttpOnly, Secure, SameSite=Strict cookie signed with HMAC (crypto/hmac, crypto/sha256). No passwords, no accounts. Rotate with POST /api/auth/rotate.
  • Mode B (pure local): no auth; the browser is the boundary.
  • Mode C (multi-user): passkeys first (go-webauthn/webauthn), argon2id passwords as a fallback (golang.org/x/crypto/argon2). Sessions in a signed cookie, refresh token in a separate cookie. OIDC is possible later as middleware; not in v1.

CSRF is handled by SameSite=Strict cookies plus an Origin header check. All state-changing endpoints require the cookie.

5.6 Filesystem safety

Every path in a request is cleaned with filepath.Clean, resolved against the vault root with filepath.Join, and rejected if it escapes (check via filepath.Rel + prefix match, not string comparison on Windows). Symlinks outside the vault are rejected explicitly. The Go tests port the same path-traversal cases from vault.test.ts so parity is checked, not assumed.

Search via ripgrep/fzf is opt-in and runs through os/exec with a fixed, hardcoded argv prefix (no shell, no user-supplied flags). Disabled by default for remote deployments.


6. Client

6.1 One bridge, two implementations

Everything in the renderer that used to call window.api.* goes through a single VaultBridge interface:

interface VaultBridge {
  listTree(): Promise<VaultTree>;
  readNote(path: string): Promise<NoteContent>;
  writeNote(path: string, body: string, etag?: string): Promise<WriteResult>;
  // …one method per current IPC handler…
  watch(handler: (ev: WatchEvent) => void): Unsubscribe;
}

Three implementations ship; a Vite env flag picks which is wired up at boot:

  • ElectronBridge — current preload window.api, unchanged.
  • HttpBridgefetch + WebSocket against the Go server. The request/response types are generated from api/openapi.yaml (via openapi-typescript and a tiny fetch wrapper), so the bridge cannot silently drift from the server's handlers.
  • FsaBridge — File System Access API, used by Mode B. Implements the same VaultBridge surface against FileSystemDirectoryHandle, with an in-memory watcher backed by a polling mtime scan (the FS Access API does not expose change events).

Nothing else in the renderer changes. Zustand store, CodeMirror, preview pipeline, search palette, command palette, theme system — all untouched. That is the whole point of going through a bridge: the port is additive, not a rewrite.

6.2 PWA shell

  • Manifest (manifest.webmanifest): display: standalone, theme colors matching the 8 ZenNotes themes, maskable icons at 192/512, scope: /, start_url: /.
  • Service worker: Workbox. Precache the app shell (JS, CSS, fonts, theme CSS, icons). Runtime:
    • GET /api/tree → StaleWhileRevalidate.
    • GET /api/notes/* → StaleWhileRevalidate, mirrored into IndexedDB for offline reads.
    • GET /api/assets/* → CacheFirst with long max-age.
    • PUT /api/notes/* → NetworkFirst with Background Sync: if offline, queue in IndexedDB and replay on reconnect.
  • Update flow: new bundle triggers a subtle banner ("Reload to apply update"), same as Electron today, but in-app.

6.3 Offline

IndexedDB has three stores:

  • notes — mirror of server responses keyed by path, value is { body, frontmatter, mtime, sha }.
  • pendingWrites — queue of writes taken while offline.
  • tree — last tree snapshot for instant startup.

Startup path, online: paint from IndexedDB immediately, revalidate from server, merge. Startup path, offline: same, just no revalidate.

Conflict rules (single-user assumption):

  • Server wins on read, client wins on write, If-Match catches true conflicts.
  • If If-Match fails, the editor shows a three-way view (mine, server's, merged) and lets the user pick. We already have a diff renderer via the unified pipeline; we reuse it.

6.4 Keyboard model

No regression. The current bindings already run in the renderer, which is now the PWA. Things to double-check on the web side:

  • Ctrl/Cmd+N, Ctrl/Cmd+W, Ctrl/Cmd+T are captured by the browser. We remap the app defaults to Leader+n, Leader+w, Leader+t first-class, and document the browser conflicts. This is already how the vim bindings work.
  • The command palette (Ctrl/Cmd+P) is claimed by some browsers' print dialog on certain OSes; we intercept via keydown and preventDefault and document the Leader+p alternative.
  • Global shortcuts do not exist on the web. zen:// deep links become https://notes.home.lan/open?path=… so links in other apps still work when the PWA is installed.

6.5 Installation

  • Chromium desktop: we show an install prompt the first time the user stars a note or pins a reference pane, not on first load. The idea is to ask only once there's something worth installing.
  • iOS/iPadOS: standard "Add to Home Screen" flow; we render an explainer if the user visits on Safari and hasn't installed.
  • We do not ship windowControlsOverlay in v1 — it's nice but not necessary.

7. Storage

7.1 Filesystem layout

Unchanged from the desktop build. The server cares about:

/vault/
  inbox/
  quick/
  archive/
  trash/
  attachements/
  .zennotes/
    server.json      ← config: auth token, ports, feature flags
    index.db         ← SQLite FTS (optional)
    state.json       ← server-side UI hints (pin order, etc.)
    token            ← generated bearer token

No database is required. A fresh vault boots with no .zennotes/ directory and the server creates what it needs.

7.2 Search index (SQLite)

  • Tables: notes(path, title, body, mtime, sha), notes_fts(title, body, content=notes), tags(tag, path), tasks(path, line, priority, due, waiting, done, body), backlinks(from_path, to_path).
  • Built on first run, maintained incrementally by watcher events.
  • If the index file is deleted, the server rebuilds it on next start. This is the user's "repair" button.

For vaults under ~2k notes, a pure in-memory index matches or beats SQLite and avoids the file. We keep both and pick by vault size.

7.3 Assets

Served via GET /api/assets/* with Cache-Control: public, max-age=31536000, immutable keyed on a content hash (not the filename). Range requests supported so videos/audio stream without loading whole files. The renderer still references them with a zen-asset:// scheme, which the bridge rewrites to /api/assets/... at paint time. That keeps the markdown file portable between desktop and web.


8. MCP in a web deployment

The existing MCP server in src/mcp/ is Node-based and uses the upstream @modelcontextprotocol/sdk. We deliberately do not port it to Go in v1:

  • The upstream SDK is well-maintained in TypeScript; the Go MCP ecosystem is still young.
  • The MCP server doesn't sit on the request hot path — it's invoked by external clients (Claude Desktop, Claude Code, Codex) against the same vault. A Node sidecar has no user-visible performance cost.
  • Keeping it in TS lets us share the existing vault-operation helpers in src/mcp/ without rewriting them against the Go server's HTTP surface.

Deployment options:

  1. Sidecar container (recommended for home servers). A second container — ghcr.io/zennotes/mcp — shares the vault volume and is reachable over stdio from clients running on the same machine (via docker exec) or over a remote MCP-capable transport like SSE-over-HTTPS when that spec stabilizes.
  2. MCP-over-HTTP bridge. The Go server can optionally expose /api/mcp that proxies to the Node sidecar, so remote clients don't need direct container access. Opt-in, off by default.

For Mode B (pure local, no server), MCP is not available. This is a known limitation: MCP needs a process, and a pure browser deployment doesn't have one. Users who want MCP pick Mode A.

The settings UI that today generates config snippets for Claude Desktop / Claude Code / Codex stays, but it now offers two variants per client: "local stdio" (desktop build) and "remote tunnel" (home server, pasted into the client's remote MCP config).

Future: once the Go server is proven, we can evaluate porting the MCP server to Go using one of the emerging community SDKs. Not a v1 concern.


9. Security

  • TLS is required. The PWA features we depend on (service worker, install prompt, notifications, File System Access, secure cookies) only work over HTTPS or localhost. We document three TLS paths: Caddy auto-HTTPS with Let's Encrypt, Tailscale Funnel, and Cloudflare Tunnel. Each is one config block.
  • Cookies: HttpOnly, Secure, SameSite=Strict, name-prefixed with __Host-.
  • CSP: strict default, script-src 'self', no inline scripts. The markdown pipeline already produces safe HTML through DOMPurify.
  • Rate-limit auth endpoints via chi middleware (httprate.LimitByIP) — 10 requests/minute on login, 100/minute on the rest.
  • Path traversal: all paths cleaned + resolved against vault root; no .. escape, no absolute paths, no symlink traversal outside root.
  • No arbitrary shell-out. ripgrep/fzf integration is opt-in, uses os/exec with a fixed argv prefix, and never interpolates user input into a shell string.
  • No telemetry from the server. No telemetry from the PWA by default. If we add optional usage metrics later, they are off by default and self-hosted (e.g., Plausible).

10. Packaging and distribution

10.1 Server

  • Primary artifact: a single static binary, zennotes-server, roughly 15–25 MB uncompressed, with the PWA bundle embedded via go:embed. Users can download it from GitHub Releases, chmod +x, and run it. No runtime dependencies.
  • Cross-compiled targets: linux/amd64, linux/arm64 (for NAS and Raspberry Pi), linux/armv7 (older Pis), darwin/amd64, darwin/arm64, windows/amd64. Produced in one make release pass with GOOS/GOARCH matrix.
  • Docker image: ghcr.io/zennotes/server:x.y.z, built FROM scratch (or gcr.io/distroless/static if we want a CA bundle baked in), ~15 MB. Multi-arch manifest for amd64 and arm64. Because the binary is static and the image is scratch, there is no shell, no package manager, and no userland CVE surface to patch.
  • Reproducible builds: -trimpath -ldflags="-s -w -buildid=", SOURCE_DATE_EPOCH, and goreleaser for release automation.
  • Compose example:
services:
  zennotes:
    image: ghcr.io/zennotes/server:latest
    restart: unless-stopped
    volumes:
      - /srv/vault:/vault
    environment:
      ZENNOTES_VAULT_PATH: /vault
      ZENNOTES_BIND: 0.0.0.0:7878
    ports:
      - "7878:7878"

  zennotes-mcp:
    image: ghcr.io/zennotes/mcp:latest
    restart: unless-stopped
    volumes:
      - /srv/vault:/vault

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data

volumes:
  caddy_data:

with a one-line Caddyfile that terminates TLS and proxies to the app:

notes.home.lan {
    reverse_proxy zennotes:7878
}

10.2 Client

The client bundle lives inside the Go binary via go:embed. On a new release, the user replaces the binary (or pulls the container), restarts, and the PWA auto-updates in browsers via the service worker. Versioning matches the desktop app's; GET /api/version returns both the server version and the embedded client's commit hash so mismatches are impossible.

10.3 Install docs

One page in the README plus one docs/self-hosting.md that covers:

  • Binary quickstart: download, chmod +x, run, open browser.
  • Docker Compose quickstart: copy Compose file, docker compose up -d.
  • systemd unit: for users who want to run the binary directly without Docker (example zennotes-server.service included).
  • TLS (Caddy, Tailscale, Cloudflare Tunnel — pick one).
  • Backups (vault is just files; rsync works).
  • Upgrades: docker compose pull && up -d, or replace the binary.
  • Pairing with the desktop app for hybrid use.

11. Phased delivery

Each phase is shippable on its own. We cut releases at each boundary.

  1. API contract. Draft api/openapi.yaml from the current IPC surface. Wire a TypeScript client-type generator into the renderer build. No backend code yet — this locks down the shape before either side is implemented. ~0.5 week.
  2. Go server skeleton. cmd/zennotes-server, chi router, config loader, health endpoint, static go:embed of a placeholder page, Dockerfile. Boots in milliseconds, does nothing useful yet. ~0.5 week.
  3. Port vault operations to Go. internal/vault package with full parity against src/main/vault.ts. Shared test fixtures drive both sides. This is the biggest single phase. ~2–3 weeks.
  4. Watcher + index. fsnotify watcher, SQLite FTS5 index with goldmark-based extraction for tags/tasks/backlinks, in-memory fallback for small vaults. ~1.5 weeks.
  5. HTTP + WebSocket handlers. Wire vault + watcher + index behind the OpenAPI routes. PWA runs against the Go server with a dev HttpBridge. Editing, search, watcher work end-to-end. ~1 week.
  6. PWA shell. Manifest, service worker, IndexedDB cache, install prompt, offline reads. ~1 week.
  7. Offline writes and conflict UI. Background Sync queue, If-Match conflict flow, merge modal. ~1 week.
  8. Auth + Docker. Bearer-token single-user auth with HMAC-signed cookies, Compose file, Caddy example, first public release of zennotes-server on GitHub Releases and ghcr.io. ~1 week.
  9. File System Access mode (Mode B). FsaBridge implementation, entry-level "open a folder in your browser" flow. ~1 week.
  10. MCP sidecar image. Package the existing Node MCP server as a second container and document remote setup. ~0.5 week.
  11. Multi-user (Mode C) — only if demand is there. Passkeys, per-user vault dirs, session store. Deferred, scoped separately.

Total to first home-server release: roughly eight to ten weeks of focused work. The Go port (phase 3) is the biggest single chunk; the rest is mechanical once the contract is locked.


12. Open questions

  • Collapse to one vault implementation. After v1, do we bundle the Go binary inside the Electron app and have the renderer talk to http://127.0.0.1:<port> in both modes? That collapses the two vault implementations into one and kills the drift-risk argument against Go entirely. Cost: Electron process model gets more complex (supervisor + child), and resource usage on desktop grows by one process. Recommendation: yes, but only after the Go server is proven in the wild for a release or two.
  • SQLite index vs on-demand scan. Large vaults (>10k notes) will need the index; small vaults don't. We ship both and switch by size. Open: where's the exact cutover, and do we let users force one?
  • Realtime multi-device editing. Not a v1 goal, but the transport (WebSocket) and the versioning (If-Match) are picked so we can graduate to CRDTs (Yjs; server side would use automerge-go or a WS passthrough) without rewriting the server.
  • Mobile editor ergonomics. CodeMirror 6 works on mobile, but vim mode without a physical Esc and modifier keys is rough. If we want mobile to be good, we need a slim virtual toolbar. Out of scope for v1; tracked.
  • MCP over remote transport. Stdio is local-only. Remote MCP is still a moving target across clients; we watch the spec and pick when it settles. Sidecar + docker exec is the bridge until then.
  • Port MCP to Go eventually? Only if the community Go SDK catches up with the TS one. No pressure to do it in v1.
  • Pure-browser MCP. There is no plausible v1 path here. Mode B users either run the desktop app or the home server when they need MCP.

13. Summary

  • The renderer already runs in a browser; the port is mostly additive on the client side.
  • Backend is Go: a single static binary (zennotes-server) built with chi + coder/websocket + modernc.org/sqlite + fsnotify, with the PWA embedded via go:embed. Ports the vault operations from src/main/vault.ts into idiomatic Go, validated by shared test fixtures and an OpenAPI contract.
  • Wire the renderer through a VaultBridge with three implementations: ElectronBridge (unchanged), HttpBridge (generated from OpenAPI, talks to the Go server), and FsaBridge (File System Access API, pure-local mode).
  • Ship a single binary + a FROM scratch Docker image + a Caddy example as the home-server story. Ship the pure-local browser mode for users who want zero backend. Keep the Electron desktop app.
  • MCP rides along as a Node sidecar container against the same vault; re-evaluate porting to Go after v1.
  • Keyboard-first and vim-first are preserved, because the whole interaction layer is untouched — we only swap the transport and re-implement the vault logic in a language better suited to running on someone's home server 24/7.