AGENT_GUIDE.md was ~33k tokens, loaded in full by every agent that followed AGENTS.md's 'read it first', whatever the task. A third of it was lookups an agent can get from the code (file map, route table, env var table, settings key list, static asset list) and had already gone stale in several places. - AGENTS.md (~1.8k tokens, always loaded): what the project is, a 'before you touch X, read Y' table, the rules that always apply, workflow (including that a push to master publishes the Docker image), and the traps that have each broken real data or a real phone. It keeps the tool-managed graft block as is. - docs/agents/*.md (11 files, read on demand): the hard-won 'why' and 'do not regress' contracts by topic, sliced from the old guide with nothing hand-rewritten. The derivable lookups are dropped; the few non-derivable facts from them (git-ignored dev compose, built bundles, reference.docx) moved to testing-and-dev.md. - pointer comments at the top of the contract-heavy source files, so the rule surfaces at the point of editing - tests/agentDocs.test.js keeps the docs honest: AGENTS.md has a size budget, every topic file is linked and says when to read it, and any file, function or npm script a doc names must still exist - README and two code comments no longer point at AGENT_GUIDE.md
15 KiB
Architecture and conventions
Read this before: changing how the server, htmx fragments, routes or the data model fit together, or when you are unsure where something belongs. Also holds the small cross-cutting contracts that have each broken before. This file records why and what not to regress. It deliberately does not say where code lives (ask the code index, or grep): maps go stale, reasons do not.
Product Direction
- Joplin Server stays unmodified
- Joplock stays separate project and separate repo
- Reuses existing Joplin Server auth/session/user model through sidecar logic
- Keeps compatibility with desktop/mobile/CLI clients on same server and same DB
- Browser stays thin and untrusted
- Shared-browser safety matters: logout should clear client-visible state/cache as much as platform allows
- Installable PWA shell, no offline notes/editing
- Uses same Postgres database as Joplin Server, no separate app DB
Architecture Overview
Stack
- Server: Node.js HTTP server, no framework
- Client: SSR HTML + htmx fragment swaps + shared browser logic in
public/app.js - Editor: Dual-mode. Markdown mode = CodeMirror 6 (mounted into
#cm-host); rendered mode = TinyMCE 8. The#note-bodytextarea is the hidden form/sync target for both. - Code blocks: Full-screen code modal with a CM6 editor and language picker. Highlighting differs by mode: markdown mode (CM6) uses CM6's own
syntaxHighlighting(joplockHighlight)with the language parsers bundled fromcm-build/; rendered mode (TinyMCE) uses the nativecodesampleplugin (PrismJS.tokenspans). highlight.js (public/hljs.min.js) is loaded on the page but is only reachable from the dead#note-previewpath — it highlights nothing live (see the dead-code note below). Rendered mode points TinyMCE's codesample plugin at fullwindow.Prismbundle (public/prism.min.js) so every language offered by modal has grammar support. Prism token colors are injected into the TinyMCE iframe viacontent_stylein_tinyMCEContentFontStyle()(the oxide dark content skin ships no.tokenCSS).codemirror.min.jsandprism.min.jsare loaded on page before TinyMCE/app.js. - Autosave: htmx delayed PUT after typing pause (deferred while modals are open)
- Markdown: server-side
renderMarkdown(), client-side TurndownhtmlToMarkdown() - Auth: reuses Joplin Server
sessionIdcookie - DB access: reads direct from shared Postgres; writes go through stock Joplin Server API
- Exports:
POST /api/export/{docx,pdf,html}inapp/routes/api.js— DOCX and PDF are rendered server-side withpandoc(PDF viaweasyprintplus pandoc's print CSS), HTML is a single self-contained file with inlined theme CSS, base64 images and base64 attachment links.public/html-docx.jsis the legacy client-side DOCX path.- Exports work in BOTH editor modes. Rendered mode posts TinyMCE HTML (
format:'html'); markdown mode posts the markdown (format:'markdown', via_exportSource()) and all three endpoints render it withrenderMarkdown()before the shared strip-links / inline-images pipeline, so images and attachments come out the same in either mode. DOCX with noformatkeeps the legacy pandoc-native markdown input. Pinned bytests/exportMarkdownMode.test.js(pandoc stubbed) and the real-container download test inplaywright-tests/md-extras.spec.js.
- Exports work in BOTH editor modes. Rendered mode posts TinyMCE HTML (
- Ops surface: admin user management (
/admin/users),pg_dumpbackup/restore (/admin/backups,/admin/restore), break-glass recovery mode (/recovery), and login rate limiting — all served by the same sidecar process. - Script load order (
app/templates/pages.js):htmx→turndown→codemirror→cm-extras→prism→tinymce→hljs→html-docx→ inline config →app.js(defer).viewport.jssits between the inline config andapp.js(it reads_joplockConfig.debug). Every local script and stylesheet carries?v=<asset version>(app/assetVersion.js, computed from the shipped files;tests/pwaHead.test.jsenforces it), which is what lets the server serve them immutable.
Runtime Shape
- Initial page load is full SSR HTML from
layoutPage()inapp/templates/pages.js - After load, most interactions are fragment-driven via htmx
- The browser is intentionally thin: most state is DOM state, form state, or small client-only UI state in
public/app.js - There is no frontend router and no SPA store
- Desktop and mobile share the same server routes and most of the same editor code; mobile is a different screen shell around the same editor fragment
Request Flow
- Browser hits Joplock
- Joplock validates
sessionIdagainst Joplin session/user tables - Fragment endpoints return HTML chunks; htmx swaps DOM
- Writes serialize note/folder/resource and send upstream to stock Joplin Server API
Main UI Flow
GET /renders the full shell- Navigation / notes / editor content is loaded from fragment endpoints
- Selecting a folder swaps the notes list or nav tree fragment
- Selecting a note swaps in
editorFragment() - Autosave sends
PUT /fragments/editor/:idwith the current form state - Preview rendering uses
POST /fragments/preview
Fragment Conventions
app/templates/*.jsreturns raw HTML strings, not JSX/templates/components- htmx targets are mostly
#nav-panel,#notelist-panel,#editor-panel, and mobile-specific targets like#mobile-editor-body - Out-of-band swaps are used sparingly; note metadata is one example
- Client logic often relies on stable IDs, so be careful renaming DOM IDs used by inline JS
Service Responsibilities
Stock Joplin Server
Owns:
- login/session/auth source of truth
- sync endpoints
- canonical storage rules
- existing user/session tables
Joplock
Owns:
- thin-client UI
- sidecar API endpoints
- session validation against shared DB
- markdown rendering and editor behavior
- resource upload/serving
- note history snapshots (
joplock_history) - per-user + admin settings and TOTP seeds (
joplock_settings) - vault metadata (
joplock_vaults) and its own session mirror (joplock_sessions) - admin user management, DB backup/restore, break-glass recovery
- note export (DOCX/PDF/HTML) and the AI provider proxy
- PWA shell/assets
Does not own:
- canonical note/folder/resource persistence rules
- sync protocol semantics
- auth/session source of truth — Joplin's
sessionstable is authoritative;joplock_sessionsonly mirrorslast_seenfor logout/heartbeat - offline-first storage
Sidecar Tables (four, no Joplin FKs)
Joplock owns exactly four tables in the shared database; every cross-reference is an application-level pointer, never an SQL foreign key:
| Table | Purpose |
|---|---|
joplock_sessions |
session_id → last_seen mirror of Joplin sessions (logout/heartbeat only) |
joplock_settings |
per-user settings JSONB, updated_time, totp_seed; user_id = '__app__' is the admin row |
joplock_vaults |
user_id + folder_id (unique) with salt and verify |
joplock_history |
note snapshots: note_id, user_id, title, body, body_hash, saved_time, indexed by joplock_history_note_time |
Physical schema reference: docs/joplock-db-schema.html. Keep it in sync when a
column changes — tables are created lazily with CREATE TABLE IF NOT EXISTS in
the service that owns them (e.g. app/settingsService.js also carries the
ALTER TABLE ... ADD COLUMN IF NOT EXISTS totp_seed migration).
How Reads vs Writes Work
- Reads come from the shared Postgres DB for speed and to match the current server state
- Writes do not write directly to Joplin tables; they go through stock Joplin Server APIs
- That split is intentional: Joplock can stay lightweight while preserving compatibility with normal Joplin clients
- If behavior looks inconsistent after a write, inspect both the sidecar request path and the upstream Joplin API call path
Design Decisions
Separate repo
Joplock lives outside Joplin monorepo. Keep standalone build, test, docs, Docker flow working without Joplin source tree.
Shared Postgres database
Joplock reads same Postgres database as Joplin Server. No data duplication. Writes still go through Joplin Server API for compatibility and validation.
Configurable open mode
Notes can open in rendered mode or markdown mode based on the per-user noteOpenMode setting (default markdown). The stored value may be preview (the pre-TinyMCE name, still accepted and preserved by normalizeSettings), but note the vocabulary mismatch: preferredEditorMode() returns only 'markdown' or 'rich', and _editorMode is never 'preview'. So noteOpenMode: 'preview' means "open rendered", and any _editorMode === 'preview' branch is dead. Desktop and mobile both respect the same setting. initEditorPanel / _completeUnlock must call preferredEditorMode() (live _joplockConfig.noteOpenMode). Do not let _tinymceReadonlyDefault() / mobile-shell read-only force rendered mode — that override made Settings → "Open notes in: Markdown" a no-op on tablet/narrow windows. Mobile read-only still applies, but only when the note actually opens in rendered mode. _joplockConfig is inlined in <head> before app.js.
Shared editor fragment
Desktop and mobile do not have separate editor implementations. Both use the same editorFragment() and client editor logic; mobile wraps it in a mobile-specific shell and screen navigation layer.
Mobile-first navigation without SPA rewrite
Mobile uses a folders screen, notes screen, and editor screen implemented in SSR + htmx + inline JS. Do not introduce a client router or framework state layer to solve mobile flow problems.
Routes and response contracts
app/createServer.js assembles the server and delegates to the handlers in
app/routes/*.js, each of which exports a handle(url, request, response, ctx)
returning true when it consumed the request (app/routes/api.js additionally
exports the export handlers and CSS/URL helpers for tests).
Routes are not listed here (grep handle( in app/routes/, or ask the code index); the contracts below are what is not obvious from them.
A few contracts worth remembering:
- The editor autosave
PUT /fragments/editor/:idalso sets theX-Note-Updated-Time/X-Note-Conflictresponse headers the client relies on (see "Plaintext save identity guard" (docs/agents/vaults-and-save-safety.md)). - Mobile note creation is driven by response headers such as
X-Mobile-Note-Id, consumed inhtmx:afterRequest. - History restore returns
editorFragmentinline (target = the editor container) with OOB swaps, not just the status bar.
If a UI action appears broken, check:
- Which endpoint it hits
- Which htmx target it swaps
- Which client event handler expects to run after swap/request
- Whether the response includes headers or OOB fragments the client depends on
Coding Guidance
- Keep changes minimal
- Preserve sidecar/frontend boundary
public/app.jsis DOM-contract fragile; validate escaping-heavy changes and stable IDs carefully- Floating menus must be opaque and stay on screen.
--bg-elevatedis a ~2% tint in most themes, so it is a panel lift, never a popup background: put popups on a solid colour (--bg-side/--bg-editor; the notebook context menu layers the tint over--bg-side). Aposition:fixedpopup also cannot be scrolled to, so place it withpositionFolderContextMenu()-style clamping instead of raw pointer coordinates, or items near the window edge become unreachable. Guarded bytests/popupBackgrounds.test.jsandplaywright-tests/folder-context-menu.spec.js. - Inline handlers only see what
public/app.jsassigns towindow. A templateonclick="foo()"needswindow.foo=foo;; a function that works in a unit test can still be "not defined" in the browser.tests/inlineHandlersExposed.test.jsscans every inline handler the templates emit and fails if a called function is not exposed - The code modal lives in
loggedInLayout, not insidenavigationFragmentoreditorFragment, so it survives htmx OOB swaps - Be careful with checkbox text handling,
\n, regex escaping, and DOM-to-markdown round trips - Keep standalone repo paths/docs/scripts correct; avoid reintroducing monorepo assumptions
- Prefer changing existing inline helpers over introducing a new abstraction unless there is clear reuse
- When fixing mobile behavior, verify desktop is unchanged
- When fixing desktop editor behavior, verify mobile still works because both use the same editor fragment
- Be cautious with
htmx:afterRequestassumptions; in htmx 2.x, response headers are often more reliable than old event-property assumptions - If changing vault behavior, verify desktop + mobile, locked + unlocked, existing note + newly-created note, and refresh/restart behavior
Debugging: If startup/resume behavior is wrong
- Check the
/render path inapp/createServer.js - Check
resumeLastNote,lastNoteId, andlastNoteFolderId - Refresh/restart must not reopen encrypted notes or notes inside vault notebooks
Small Contracts That Are Easy To Regress
Behaviours that are documented nowhere else and have each broken before (verified against the code; the larger contracts have their own sections above).
- Vault chrome follows the folder.
_syncEditorVaultChrome(noteId, inVault, unlocked)(public/app.js) injects/removes the lock-toggle button in.editor-titlebarwhen a note is moved into a vault (called from_doEncryptNoteInVault). Vault notes cannot be moved out (see Vault / Encryption Model indocs/agents/vaults-and-save-safety.md). - Live search keeps what you typed. The sidebar search value is saved around the htmx swap (
_navSearchSavedValue, captured athtmx:beforeSwap) so characters typed while a request is in flight are not lost. - Search and note lists are paginated. Desktop and mobile lists end in a "Load more" button (
.notelist-load-more), not infinite scroll; search is backed by thepg_trgmindexensureIndexes()creates. - TinyMCE autosave sync has a fast path and a debug path. By default
onEditonly callsmarkEdited()+scheduleSave(), and_lazyTinyMCESyncBeforeSave()runs once beforeformHash()inscheduleSave/scheduleSaveTitle/_activeEditorIsDirty; with admin debug logging on, every event syncs and logs.ExecCommand+SetContentare wired so the blocks dropdown andinsertContentmutations still sync.flushSave's hash-unchanged branch setsSaved(without that a phantomEditedblocked the sidebar's notebook "+" button). - Read-only is one function.
_applyFormReadonly()locks the title contenteditable, folder select, toolbar buttons/inputs and the CM6contentDOM;.editor-readonlyinstyles.cssis the visual. On the mobile shell a note that opens in rendered mode is read-only until the pencil (jop_edit) is tapped;_tinymceReadonlyDefault()and_tinyMCEToolbarSpec()read the cachedisMobileShellMode(). - The mobile editor body shows an inline spinner (
.mobile-loading-note) while a note loads. Aposition: fixedoverlay does not work there (it ends up under the shell's fixed layers). renderMarkdown()stripshx-*attributes from user HTML so a note cannot inject htmx behaviour.