dsh-sidebar-onlyoffice
September 2, 2026 · View on GitHub
A DeepSeek Harness (DSH) web plugin that opens and edits .docx / .xlsx / .pptx in the dsh-better-sidebar file sidebar through a self-hosted ONLYOFFICE Document Server — with JWT-signed configs, atomic save-back to disk, and live refresh when the AI edits an open file.
Features
- Real office editing — the full ONLYOFFICE editor (words / sheets / slides) embedded in the better-sidebar viewer, not a static preview. Saves write back to the file on disk.
- Same-origin proxy (always on) — the whole Document Server browser UI (api.js, editor iframe, statics, co-authoring socket) is reverse-proxied through dsh web itself at
/sidebar/onlyoffice/ds: the DS can stay on a private network the browser never reaches. - Signed and fenced by default — editor configs are HS256-JWT-signed when a secret is configured; every file download is gated by a short-lived HMAC token; browser-facing routes sit behind the dsh web trust fence; files outside the session working directory are refused.
- Live refresh on AI edits — when the agent modifies a file that is open in the editor, the viewer swaps to the new version within ~a second via
refreshFile, with no page reload and no api.js reload. A dirty editor is never auto-refreshed (a banner offers a manual reload instead). - Atomic saves, self-save suppression — Document Server callbacks download the saved bytes and replace the file through temp-file + rename, serialized per document key; the plugin's own writes are absorbed by the watch hub so saving never echoes a refresh back.
- In-network save fetch — save URLs the DS reports against the public entry are rewritten onto
documentServerUrl(container-to-container) instead of hairpinning through the public reverse proxy. - Coexists with the office preview plugin — viewer ids
onlyoffice:docx|xlsx|pptx(priority 10) never clash with@huanlin/dsh-plugin-better-sidebar-plugin-office'sdocx/xlsx/pptx(priority 0); either side can be disabled per-viewer in the side card settings.
How it works
| Half | Location | Responsibility |
|---|---|---|
| Host (server) | src/ | Registers four routes on the dsh web webServer implementing the "document storage service" role from the official ONLYOFFICE integration docs. |
| Browser (client) | src/client/ | Registers the three better-sidebar file viewers; loads the Document Server api.js on demand, mounts DocsAPI.DocEditor, subscribes to the SSE watch stream. |
better-sidebar viewer (onlyoffice:docx|xlsx|pptx)
├─ GET /sidebar/onlyoffice/config (browser, trust-fenced)
│ → editor config + JWT + api.js URL + HMAC file token
├─ GET /sidebar/onlyoffice/file (Document Server; HMAC token = auth)
├─ POST /sidebar/onlyoffice/callback (Document Server; status 2/6 → atomic save-back)
├─ GET /sidebar/onlyoffice/watch (browser SSE; inotify-fed disk changes)
│ → change → re-fetch config → docEditor.refreshFile(config) [no reload]
└─ /sidebar/onlyoffice/ds/** (browser ↔ Document Server, same-origin)
→ api.js + editor iframe + statics + socket.io (WS JIT-routed, polling fallback)
- Node half — the config route builds the editor config for an absolute path inside the session cwd (documentType mapping, content-addressed
key = sha256(host+path+size+mtime)), signs the whole config as an HS256 JWT, and registers the key→file mapping. The file route serves the raw bytes to the Document Server. The callback route (JWT-verified) downloads saved bytes on status 2/6 and atomically replaces the file, forgets the key on status 4, and always acknowledges with{"error":0}. The watch route streams disk changes as SSE, fed by an inotify watch hub (fs.watchon the file and its directory, debounced, signature-filtered through the same content-addressed key). - Browser half — loads the Document Server api.js once per URL, mounts the editor, calls
destroyEditor()on teardown; failures show an error panel plus a download fallback link (reusing better-sidebar's/sidebar/fileroute). While mounted it subscribes to the watch stream and reacts to outside (AI) edits by re-fetching the config and callingdocEditor.refreshFile(config).
Live refresh on AI edits
When an agent modifies a file that is open in the ONLYOFFICE editor, the viewer finds out within ~a second: inotify → SSE change event → config re-fetch (whose content-addressed key changed with the file) → refreshFile. The editor iframe itself is reused — no page reload, no api.js reload, and the DS opens the new version as a fresh session (old key closed with status 4). Guard rails:
- A dirty editor is never auto-refreshed —
refreshFileunconditionally drops unsaved edits (noisDocumentModifiedguard on the integrator path, verified in the DS source). Instead a banner appears ("file changed on disk — reloading discards unsaved edits") with a manual Reload button; clicking it is the user's confirmation. - The DS's own
onRequestRefreshFile(fired on reconnect / same-key saves, only while NOT modified) rides the same refresh path. - The plugin's own callback saves are suppressed server-side (
noteSelfSaverecords the just-written signature), so a user save doesn't bounce a refresh back and reset the cursor. - File deletion pushes a
removednotice; re-creation pushes achange.
Requirements
- A DSH web profile (
dsh web), Node.js ≥ 20, with the dsh-better-sidebar plugin installed. - A self-hosted ONLYOFFICE Document Server (verified against 9.4 community edition) that can reach the dsh web server over the network. The browser needs no access to it (the proxy mount is the only browser-facing DS entry).
- With JWT enabled (the DS default), the plugin's
jwtSecretmust equal the DSJWT_SECRET. - The DS needs
ALLOW_PRIVATE_IP_ADDRESS=truewhendocument.url/callbackUrlare private addresses (the DS refuses them by default).
Installation
From the npm registry (prebuilt — no build permission needed):
dsh plugin --profile web add dsh-sidebar-onlyoffice
From a GitHub repository (source — pnpm runs the prepare build; allowlist the package in profiles/web/pnpm-workspace.yaml if pnpm blocks the build script):
dsh plugin --profile web add github:chendefine/dsh-sidebar-onlyoffice
Or through the DSH plugin marketplace (设置 → DSH插件市场) — the repo carries the dsh-plugin topic and is indexed automatically.
After a bundle plugin is added to the profile layer stack, write your config into the profile's cordis.patch.yml layer (see below), restart dsh web, and hard-refresh the browser (Ctrl+Shift+R). Uninstall with dsh plugin --profile web remove dsh-sidebar-onlyoffice and restart again.
Configuration
All keys are optional; the profile's cordis.patch.yml layer carries them:
- id: dsh-sidebar-onlyoffice
config:
jwtSecret: "<the DS JWT_SECRET>" # empty = unsigned (only for JWT_ENABLED=false servers)
# documentServerUrl: http://onlyoffice-documentserver # the DS entry dsh web reaches directly (its own nginx)
# internalBaseUrl: http://172.31.255.4:3080 # how the DS reaches dsh web; empty = auto-detect
# publicBaseUrl: https://work.example.com # browser-visible origin when a gateway rewrites Host; empty = request Origin
# defaultMode: edit # edit | view
# username: User # editor display username (top-right corner)
# fileLimitMb: 100
# tokenTtlSec: 600
| Field | Default | Description |
|---|---|---|
jwtSecret | '' | Shared secret with the Document Server (its JWT_SECRET). Editor configs and callbacks are JWT-signed/verified when set; empty only fits a DS running JWT_ENABLED=false. |
documentServerUrl | '' | The one Document Server address: the entry dsh web itself reaches directly — the DS container's own nginx on the docker network (http://onlyoffice-documentserver) or a published host port. Doubles as the reverse-proxy upstream and the base saved-document bytes are fetched through. |
internalBaseUrl | (auto-detect) | Base URL the DS uses to reach this dsh web server (document download + callbacks). Empty = auto-detect the first non-loopback IPv4 + the webserver port. |
publicBaseUrl | (request Origin) | Browser-visible origin (scheme://host[:port]), used when minting X-Forwarded-Host/Proto and matching DS-reported save URLs. Set it when a fronting gateway rewrites the Host header away from the browser-visible authority; empty = derive from each request's Origin. |
defaultMode | edit | Default mode for newly opened files (edit/view). |
username | User | Editor display username (editorConfig.user.name, the co-author name in the editor's top-right corner); empty reverts to User. Reopen files after a change. |
fileLimitMb | 100 | Max file size (MB) for serving and saving back. |
tokenTtlSec | 600 | Signed URL token lifetime in seconds. |
Legacy pre-0.2.0 keys (proxy, proxyUpstream, internalDocumentServerUrl, proxyPublicBase, documentServerPort) are mapped once at startup — proxyUpstream/internalDocumentServerUrl onto documentServerUrl, proxyPublicBase onto publicBaseUrl — with a warning; move to the new names.
Graphical configuration (settings card)
When the composition mounts the DSH settings service (the standard dsh web bundle does), the plugin registers a settings section under the dsh-sidebar-onlyoffice namespace and the web client's Settings → Plugins tab renders a configuration card for it — all eight fields, zh/en copy, staged edits with a save/discard footer. A committed change is written to the deployment's user settings (<DSH_HOME>/settings.yaml), layered over the composition entry, and applies without a restart: both the route closures AND the reverse proxy read the live section (jwt/mode/username/limit/token/URLs take effect on the next editor-config fetch; a changed documentServerUrl swaps the proxy upstream at once — the discovered versioned prefix resets and re-probes, and an address that first resolves through the card claims the mount immediately). A deployment may therefore boot with an empty composition entry and be configured entirely through the card.
Precedence per field: card/user layer → the composition entry (cordis.patch.yml config: block) → schema defaults. Removing a card edit (the "Reset to default" control) reverts to the composition layer; hand-editing settings.yaml works too (the section re-resolves live).
Save-URL rewrite (in-network fetch)
Two directions are configured independently: DS → this server via internalBaseUrl (document download + callbacks), and this server → DS for the saved bytes its callbacks report. The DS mints those save URLs against the browser-visible base the proxy forwards (publicBaseUrl, else the request Origin) — behind a reverse proxy they would hairpin out through the public entry (DNS + TLS + terminator + gateway). The plugin rewrites the minted prefix onto documentServerUrl (e.g. http://onlyoffice-documentserver, the docker-network container name) — path suffix and query stay intact — for a direct container-to-container fetch.
Safety verified against Document Server 9.4: /cache/files auth is an nginx secure_link md5 over expires + request-path + server secret, where the request path is the one AFTER the proxy strips the sub-path prefix — host and stripped prefix are not signed material, so a rewritten URL still returns 200. A reported URL outside the public base is fetched as-is with a warn log.
Same-origin proxy
The plugin is proxy-only: it always reverse-proxies the Document Server's whole browser UI through the dsh web port at /sidebar/onlyoffice/ds. The config route hands out that mount's api.js URL, and everything the editor then loads — the iframe, sdkjs/web-apps statics, fonts, the co-authoring socket, exports and downloads — flows back through dsh web. The browser never needs to reach the DS at all; point documentServerUrl at the DS container entry and the DS can stay on a private network. This also removes the mixed-content pitfall: an https GUI proxies an http-only DS transparently.
How it cooperates with the DS (9.x; the same generation the plugin targets):
- api.js derives the DS base from its own script URL, and the editor derives the socket path from the iframe URL — both stay under the mount automatically.
- The proxy sends
X-Forwarded-Host: <browser host>/sidebar/onlyoffice/ds(the official ONLYOFFICE "virtual path" shape), so the URLs the DS itself mints (the versioned script-caching 302s, save/download links) point back into the mount; absoluteLocationheaders are additionally rewritten to mount-relative form. The same headers ride the WebSocket upgrade leg, where the DS derives the/cache/filesdocument-data base from the connection. - The editor's socket.io path is
<mount>/<version>~<hash>/doc/<key>/c, where<key>is the plugin's own content-addressed document key. The proxy registers that exact WebSocket upgrade route just-in-time per opened document (disposed on the DS's status-4 close, LRU-capped, re-discovered when a DS upgrade moves the versioned prefix). A missed registration degrades softly: the editor falls back to socket.io polling over plain HTTP — editing keeps working. - Both proxy legs sit behind the same browser-trust fence as every other plugin route (same-origin editor passes; cross-site pages are refused).
Notes and limits:
- Requires a DS of the 9.x generation (sub-path URL minting).
- Every document save changes the key (content-addressed), so upgrade routes churn per save — disposed on close, capped, invisible in practice.
- If a gateway in front of dsh web rewrites
Host(e.g. to127.0.0.1:3080), URLs minted inside DS responses would carry that unreachable host: setpublicBaseUrlto the browser-visible origin. - Security trade-off: the DS UI runs same-origin with the DSH page, so a compromised DS could script the dsh origin. The fence still stops cross-site callers; keep the DS trusted.
- Static assets hop twice (browser → dsh web → DS); the versioned immutable caching survives the hop, but an outer gateway may need its cache map extended for
/sidebar/onlyoffice/ds/**for best performance.
Deployment shape
The canonical deployment: the Document Server container and dsh web share a docker network (DS container name resolvable, e.g. onlyoffice-documentserver — that is documentServerUrl), JWT enabled with a fixed secret, and ALLOW_PRIVATE_IP_ADDRESS=true on the DS container. The DS needs no published browser-facing port at all.
Troubleshooting: editor reports errorCode:-4 "download failed"
-4 means the DS could not download document.url. Start from the target URL in the DS container log (error downloadFile:url=...):
- URL points at the DS itself →
internalBaseUrlwas misconfigured to the DS address. It must be the address the DS uses to call back into dsh web (use the container name when both share a docker network); editing the profile'scordis.patch.ymlhot-applies via Cordis HMR, no restart needed. - 404 → wrong host in the URL; 403 → token expired/bad signature (reopen the file after a dsh web restart); connection refused/timeout → network unreachable (both containers must share a network; the DS needs
ALLOW_PRIVATE_IP_ADDRESS=true). - Quick check without a browser: fetch the config route with
Host: localhostto getdocument.url, thendocker exec onlyoffice-documentserver curl -v <that URL>— expect 200.
Known limits
- Saves are whole-file overwrites: concurrent writes to the same file by the agent race (last writer wins). Live refresh narrows this window — a clean editor is pushed to the newest version within ~a second — but a dirty editor that saves after an AI edit still overwrites it; the stale banner is cleared on save (the overwrite resolved the divergence).
- Self-save suppression has a millisecond race: an external write landing between the plugin's save and its
noteSelfSavestat can be wrongly absorbed (one missed refresh event; the next change recovers). Negligible in practice. onRequestRefreshFilerequires ONLYOFFICE Docs ≥ 8.3;refreshFileitself was verified against Document Server 9.4 community edition.- The Document Server must be of the 9.x generation (sub-path URL minting); the WebSocket leg depends on just-in-time route registration and silently degrades to socket.io polling when it misses (older servers or route conflicts).
- The key→file registry persists under
<DSH_HOME>/plugins/dsh-sidebar-onlyoffice/registry.json(newest 128 entries), so a save redelivered after a dsh web restart still lands. The watch stream reconnects on its own (EventSourceretry: 3000); a viewer left open across the restart re-fetches its config on the next interaction.
Security
The host half only ever serves and writes files inside a session working directory, browser-facing routes sit behind the dsh web trust fence, and every Document-Server download is gated by a short-lived HMAC token minted per config request. The Document Server itself is user-deployed and user-configured — deploy it on a trusted network. See SECURITY.md for the full stance and threat model.
Development
pnpm install
pnpm run typecheck # type gate
pnpm test # vitest (JWT/key/tokens, route fences and callback saves, in-network save-URL rewrite, the same-origin ds proxy, settings wiring, viewer descriptors, inotify watch hub + SSE route)
pnpm run build # lib/index.js (node half) + lib/client.js (ModuleLoader-wrapped browser half)
Repository layout:
src/
├── index.ts # host entry: the four webServer routes (+ dsProxy wiring)
├── dsProxy.ts # same-origin DS reverse proxy: mount, forwarding headers, JIT WS routes
├── config.ts # schemastery schema, base-URL derivation/detection
├── settings.ts # settings-section wiring (the graphical config card's host half)
├── onlyoffice.ts # editor config, JWT payload, file tokens, save-URL rewrite
├── jwt.ts # minimal HS256 sign/verify (no dependencies)
├── registry.ts # document key → file mapping, atomic save-back, persistence
├── watch.ts # inotify watch hub (debounce, signature filter, self-save absorption)
├── trust-fence.ts # browser-request trust check (host/origin)
├── paths.ts # absolute-path + containment helpers
├── wire.ts # JSON body/error helpers
└── client/ # browser half: viewers, editor mount, i18n, settings card