diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md
index 4b97db3..e0f87f7 100644
--- a/AGENT_GUIDE.md
+++ b/AGENT_GUIDE.md
@@ -150,11 +150,12 @@ Joplin notebooks nest via the folder's `parent_id`. Joplock renders, creates, mo
- **Structure:** not into itself or a descendant (mirrors Joplin's `canNestUnder`). Same-parent move is a no-op.
- **Delete = promote.** Sub-notebooks move up to the deleted notebook's parent; its notes move to that parent (top level: the existing top-level "General", created if missing). Deleting never deletes notes or sub-notebooks. (Old behaviour moved every note to General and orphaned sub-notebooks.)
-- **Vaults are top-level leaves.** Cannot contain notebooks, cannot be moved, cannot be created nested (`POST /api/web/vaults` refuses a nested notebook or one with children), cannot end up in a share. The new-notebook modal disables the parent picker when "vault" is ticked. The sync proxy rejects (403) a notebook PUT whose parent is a vault, so an external Joplin client cannot hang plaintext notebooks under a vault.
+- **Vaults are top-level leaves.** Cannot contain notebooks, cannot be moved, cannot be created nested (`POST /api/web/vaults` refuses a nested notebook or one with children), cannot end up in a share. The new-notebook modal disables the parent picker when "vault" is ticked. `vaultProxyGuard` also rejects a notebook PUT whose parent is a vault, **but see "Sync-proxy guards do not apply to stock Joplin clients" below: that guard is currently inert for real clients.**
- **Sharing:**
- Only the owner creates/moves/deletes inside a share (recipients get 403).
- A **share root stays top level** (Joplin's rule): moving one under anything is refused ("Stop sharing…"); sharing a nested notebook **lifts it to the top level first** (`prepareForShare`; the share dialog says so); a notebook already inside a share cannot be shared again; a subtree containing a vault cannot be shared.
- `ensureShareIdsOnNotebook` now stamps the **whole subtree** (every sub-notebook and the notes directly in each), writing `share_id`/`is_shared` into the item JSON **and** the `jop_share_id` column (falls back to JSON only if a server lacks the column). Creating a sub-notebook in a share inherits it; moving a subtree into/out of a share re-stamps it and grants/revokes recipient `user_items` (`createShareSync.setSubtreeShare`).
+ - **Subtree share changes go through the API.** `createShareSync.setSubtreeShare` re-saves every notebook and note of the subtree with the new share fields (`itemWriteService`, lossless thanks to pass-through) so Joplin Server emits change events. Silent database writes are invisible to stock clients: a subtree moved **out** of a share used to stay in the recipient's Joplin app. Items the API cannot rewrite (Joplin-encrypted) get the database sweep only. Verified with a real client recipient in `integration-tests/joplin-sharing.test.js`.
- **Stopping a share clears the dead `share_id` from the owner's whole tree** (notebook, sub-notebooks, notes; JSON and `jop_share_id`), like Joplin's own `unshareFolder`. Left in place, Joplin Server answers any later write of those items with `share not found` (422 — the note could no longer be edited or trashed) and **silently refuses to delete a root folder that still carries a share id** (returns 200, does nothing). Pinned by `playwright-tests/nested-share.spec.js` step 6.
- Recipient access keeps the existing Joplock convention: `user_items.item_id` holds the **`jop_id`** (Joplin Server's own fan-out uses `items.id`, which `itemAccessExpression` does not read).
- `updateFolder` now preserves `created_time`, `user_created_time`, `icon` and `master_key_id` (a rename/move used to reset them).
@@ -169,6 +170,38 @@ Joplin notebooks nest via the folder's `parent_id`. Joplock renders, creates, mo
Verified live while building this: a **note created in a shared notebook after the share** is not visible to a Joplock recipient, and a **note moved out of a share stays visible** to the recipient (the note write paths never call grant/revoke; only folder operations do). Fixing means calling `createShareSync.setItemsShare` from the note create/move paths.
+## Joplin Field Fidelity (do not regress)
+
+Joplock shares one account with stock Joplin clients, so **a Joplock write must change only what the user edited**. Originally `serializeNote`/`serializeFolder` rebuilt every item from hard-coded values: a single body edit turned a to-do into a plain note, wiped author/source URL/location, flipped an HTML note to markdown, reset `order` and overwrote `source_application`. Measured with the real Joplin CLI synced through Joplock's proxy, then fixed.
+
+### How it works
+
+- **Pass-through serializer** (`app/items/joplinItem.js`): reproduces Joplin's `serialize` / `serialize_format` (canonical key order, ISO times, `\n` escaping, no empty body block). Values come from the item's **stored fields** and only the fields Joplock owns are overridden.
+- **Items carry their stored fields.** `mapNoteRow` / `mapFolderRow` attach `fields` (the parsed `items.content` JSON). Every write path passes `existing`, so `serializeNote({ fields: existing.fields, ... })` writes everything else back untouched, including fields Joplock has never heard of (`is_locked`, `extracted_resource_ids`, `user_data`, `application_data`, ...). Defaults (`order = now`, zero geo strings, `markup_language 1`, `source_application net.cozic.joplock-web`) apply **only to brand-new items**; edited items keep their original `source_application`.
+- **Joplock-owned fields:** title, body, parent, deleted time, `is_shared`/`share_id`, `updated_time` (always now, sync needs it). `user_updated_time` moves **only on a real title/body edit**; move, trash, restore and share changes preserve it (Joplin's `moveToFolder` semantics).
+- **`fields` is internal.** `publicItem()` / `publicItems()` (`routes/_helpers.js`) strip it from JSON API responses. Keep using them on any new endpoint that returns a note or notebook.
+- **The server only stores fields its own Joplin library knows** (it drops the rest on upload and when serving), so "unknown to Joplock" fields that can persist are ones a current Joplin writes. Joplin clients themselves also drop unknown fields on read.
+- **Share id lives in a column.** Joplin Server moves `share_id` out of the item JSON into `items.jop_share_id` on the first API save. Always read it via `shareIdOfRow()` (column first, JSON fallback) / `shareIdOf()` in SQL. Reading only the JSON made a shared note look unshared after its first edit, so the *second* Joplock edit wrote `share_id: ''` and silently took the note away from every recipient.
+
+### Items Joplock must treat carefully
+
+| Item | Behaviour |
+|---|---|
+| `markup_language = 2` (HTML note) | Shown **rendered and read-only** (`app/htmlNoteRenderer.js`, allowlist sanitizer on untrusted HTML + TinyMCE's own sanitizing). Form is `data-html-note="1"`: no markdown/rendered switch, no toolbar, title not editable, `tinyMCESyncToTA` is a no-op. The server refuses any body change (403) but still allows move/trash/restore. Never convert it to markdown. |
+| Joplin end-to-end encrypted (`jop_encryption_applied = 1`) | **Locked placeholder only.** `e2ee: true`, title/body blanked, no editor form at all. `updateNote`/`updateFolder` throw 403 (`assertNotE2ee`); a live one cannot be hard-deleted; a notebook holding any cannot be deleted (409, checked **before** any write so a delete never half-completes). |
+| `is_conflict = 1` (conflict copy) | **Hidden** from lists, counts, headers and search (`conflictFilterSql`), like Joplin's "Conflicts" notebook. Stays untouched, and travels with its notebook when that is deleted (`includeConflicts`). |
+| Attachments | `serializeResource` writes Joplin 3.x's OCR fields; `ocr_driver_id` is `1` (Joplin's default; `0` is a legacy quirk). |
+
+### Real-client integration tests (opt-in)
+
+`npm run setup:joplin-cli` installs the real Joplin terminal client under `~/.cache` (not in the repo). `npm run test:joplin` (dev stack up, admin env vars set) drives it headless through Joplock's `/joplin` proxy using a dedicated `compat-client@joplock.test` account (`integration-tests/joplin/harness.js`): `joplin-fidelity`, `joplin-guards`, `joplin-nested`, `joplin-sharing`. They create items in the client, sync, edit/move/trash in Joplock, sync back and compare **field by field** from the client's own SQLite. Raw uploads authenticate with `X-API-AUTH`, like a real client. Joplin Server applies share changes on a 10 s task, so the sharing tests wait ~13 s per step. One test is a tracked `todo` (below).
+
+### Known issues found with the real client (not fixed)
+
+- **Sync-proxy guards do not apply to stock Joplin clients.** `vaultProxyGuard` and `shareProxyGuard` identify the user with `authenticatedUser()`, which reads only Joplock's `sessionId` **cookie**. Joplin apps authenticate with an `X-API-AUTH` header and send no cookie, so both guards "stream through". Verified: a real client wrote a **plaintext note into a vault notebook and created a notebook under a vault**, and nothing stopped it. The vault/share/"no notebook under a vault" proxy protections described elsewhere in this guide therefore only hold for browser-origin traffic. Making them live needs `authenticatedUser` to accept `X-API-AUTH` **and** a fix for a second bug: when a request does authenticate, both guards call `bufferRequest()` on the same stream, so the second read never completes and the request hangs (seen as a hung cookie-authenticated PUT to `/joplin/...`). Behaviour will change for existing clients (403s on vault writes), so treat it as its own change.
+- **Collaborator content is invisible to the share owner in Joplock.** Access checks use Joplock's own `user_items` rows keyed by `jop_id`; Joplin Server keys them by `items.id`. A note a collaborator creates in a Joplin app never shows for the owner in Joplock (it does in the owner's Joplin apps). Tracked as the `todo` test in `joplin-sharing.test.js`. Fixing it means changing the access model together with Joplock's own removal paths (otherwise removed users keep access).
+- Notes created in a share after sharing do not reach a **Joplock** recipient, and a note moved out of a share stays visible to a Joplock recipient (the note write paths never grant/revoke Joplock's `jop_id` rows; only folder operations do). Stock-client recipients are handled correctly by the server.
+
## Core Rules
1. Do not modify Joplin Server source for Joplock features unless explicitly approved.
@@ -207,7 +240,7 @@ Server (`app/routes/_helpers.js`) rules:
- `assertVaultNoteBodyEncrypted(vaultService, userId, existingParentId, targetParentId, body, noteId, opts)` parses the ciphertext blob and rejects the write when `meta.vault` mismatches the target folder's vault, or `meta.noteId` (if present) mismatches the target note.
- **Vault boundary enforcement** (three layers):
1. **ParentId immutability / no conflict copies**: if a note currently lives in a vault folder, the server rejects any PUT that changes its `parentId` (400: `Vault notes cannot be moved to a different folder`). Vault notes cannot change folder — the folder select is disabled client-side and the server enforces this on every write path. Conflict `createCopy` is also rejected for vault notes: ciphertext is bound to source note id, while copying unlocked DOM content would make a plaintext duplicate.
- 2. **Ciphertext required inside vaults**: saving to a vault folder always requires an encrypted body. Same-folder saves and sync-proxy writes are both covered. `enforceExistingVault=true` (sync-proxy only) extends this to prevent external Joplin clients from stripping ciphertext by re-parenting.
+ 2. **Ciphertext required inside vaults**: saving to a vault folder always requires an encrypted body. Same-folder saves are covered; sync-proxy writes are covered **only for cookie-authenticated (browser) traffic, not stock Joplin clients** (see "Known issues found with the real client"). `enforceExistingVault=true` (sync-proxy only) extends this to prevent external Joplin clients from stripping ciphertext by re-parenting.
3. **Metadata integrity**: encrypted blobs carry `vault` (folder id) and `noteId` (bound target); both are validated against the destination to prevent cross-vault or cross-note ciphertext smuggling.
- **Client-side**: the folder select (`#editor-folder-select`) is rendered `disabled` for vault-protected notes and stays disabled after unlock — vault notes cannot change parent folder through the UI. The only reachable folder-change path is plain→vault (encrypt on move).
- Every note-write path passes the target `noteId`: `app/routes/api.js` (PUT), `app/routes/fragments.js` (autosave PUT), `app/routes/history.js` (restore), `app/proxy/vaultProxyGuard.js` (sync proxy).
@@ -373,6 +406,7 @@ the service that owns them (e.g. `app/settingsService.js` also carries the
### Tests
- `tests/*.test.js` — unit/integration tests (`node:test`; several suites extract real functions out of `public/app.js` and run them under a JSDOM harness)
- Run: `npm test` (i.e. `node --test tests/*.test.js`)
+- Real Joplin client compatibility (opt-in, needs the dev stack): `npm run setup:joplin-cli` once, then `npm run test:joplin` (`integration-tests/*.test.js`)
- `playwright-tests/*.spec.js` — browser E2E against a live dev stack; run with `npm run test:ui`
### Deployment
@@ -966,6 +1000,7 @@ Recommended inner loop:
## Recently Completed Work
+- **Joplin field fidelity + compatibility harness**: pass-through serializer, stored `fields` on every item, HTML notes rendered read-only, Joplin-E2EE items as locked placeholders, conflict copies hidden, share id read from `jop_share_id`, OCR fields on uploads; opt-in real-client tests (`npm run test:joplin`). Also fixed along the way: **restoring a note from the trash returned 404 for every trashed note** (the ownership lookup ignored trashed notes), and **editing a shared note twice in Joplock silently unshared it**. See "Joplin Field Fidelity".
- **Nested notebooks**: Joplin's `parent_id` hierarchy now renders as a tree on desktop (multi-expand, rolled-up counts, ancestors open) and mobile (inline expandable tree), with create-under-parent, move (tree picker), delete-promotes, whole-subtree sharing, and vaults as top-level leaves. `app/items/folderTree.js` + `app/items/folderOps.js`. `updateFolder` also stopped resetting `created_time`/`icon`.
- **Rich-mode uploads now insert through the live editor**: uploading from the modal or the older `#file-upload` picker in rendered mode used to target the hidden `#note-body` textarea, which TinyMCE's own lazy sync overwrote before the debounced save fired — the attachment silently vanished. `_captureUploadInsertTarget()` now captures a TinyMCE caret range (`{mode:'tinymce', rng}`) when the host is visible and the editor is not read-only, and `_insertResourceIntoTinyMCEFromMarkdown()` rebuilds the ` hello world`/`` from the saved `` markdown, inserts it with the same `_tinyMCEBlockAttachmentHtml()` padding as drag-drop, and syncs `#note-body` immediately. All three rich-mode insert paths call `_endTinyMCEPostLoadWindow()` so the insert is treated as a real edit instead of post-load echo (otherwise the reconcile re-baselines the hash and the save sees "unchanged"). Read-only (mobile rendered) keeps the textarea/CM fallback. See "Upload behavior" above.
- **flushSave uses `keepalive: true`**: the unload-time flush (visibilitychange, tab close, note-switch navigation) was being aborted mid-flight — Chromium sent a truncated request with no `Cookie` header (401) and the pending edits were lost. Pinned by `tests/saveIdentityGuard.test.js`.
diff --git a/app/createServer.js b/app/createServer.js
index 3d7a106..cb5034b 100644
--- a/app/createServer.js
+++ b/app/createServer.js
@@ -135,7 +135,7 @@ const createServer = options => {
itemService,
itemWriteService,
vaultService,
- shareSync: database ? routeShares.createShareSync({ itemService, database }) : null,
+ shareSync: database ? routeShares.createShareSync({ itemService, itemWriteService, database }) : null,
});
const upstreamRequestContext = _request => ({
diff --git a/app/items/folderOps.js b/app/items/folderOps.js
index e215d5f..a1a33cd 100644
--- a/app/items/folderOps.js
+++ b/app/items/folderOps.js
@@ -105,6 +105,7 @@ const createFolderOps = ({ itemService, itemWriteService, vaultService, shareSyn
if (shareFields.shareId && shareSync) {
await shareSync.setSubtreeShare({
ownerId: user.id, folderId: created.id, shareId: shareFields.shareId, previousShareId: '',
+ sessionId: user.sessionId, requestContext,
});
}
return { id: created.id, parentId: parent, ...shareFields };
@@ -168,6 +169,7 @@ const createFolderOps = ({ itemService, itemWriteService, vaultService, shareSyn
if (shareChanges && shareSync) {
await shareSync.setSubtreeShare({
ownerId: user.id, folderId, shareId: newShareId, previousShareId: oldShareId,
+ sessionId: user.sessionId, requestContext,
});
}
return { changed: true, folder: state.folder, shareChanged: shareChanges };
@@ -218,6 +220,7 @@ const createFolderOps = ({ itemService, itemWriteService, vaultService, shareSyn
if (childShareId !== newShareId && shareSync) {
await shareSync.setSubtreeShare({
ownerId: user.id, folderId: child.id, shareId: newShareId, previousShareId: childShareId,
+ sessionId: user.sessionId, requestContext,
});
}
}
diff --git a/app/items/itemService.js b/app/items/itemService.js
index 0f26908..93bced3 100644
--- a/app/items/itemService.js
+++ b/app/items/itemService.js
@@ -12,6 +12,14 @@ const decodeItemContent = content => {
return JSON.parse(raw);
};
+// Joplin Server keeps an item's share id in the `jop_share_id` COLUMN and drops it
+// from the stored JSON as soon as the item is saved through the sync API (any save,
+// by Joplock or a Joplin app). The JSON copy only exists on items Joplock stamped
+// itself and goes stale. Reading only the JSON made a shared item look unshared after
+// its first edit, so the next Joplock edit wrote `share_id: ''` and silently took the
+// note away from every recipient.
+const shareIdOfRow = (row, content) => `${row.jop_share_id || content.share_id || ''}`;
+
// Items encrypted by Joplin's own end-to-end encryption (not Joplock vaults).
// The stored content is ciphertext: title/body are empty and rewriting the item
// from here would replace the ciphertext with plaintext. They are shown as a
@@ -40,8 +48,8 @@ const mapFolderRow = row => {
masterKeyId: content.master_key_id || '',
updatedTime: Number(row.jop_updated_time || content.updated_time || 0),
ownerId: row.owner_id || '',
- shareId: content.share_id || '',
- isShared: !!(Number(content.is_shared || 0)),
+ shareId: shareIdOfRow(row, content),
+ isShared: !!(Number(content.is_shared || 0)) || !!shareIdOfRow(row, content),
};
};
@@ -93,8 +101,8 @@ const mapNoteRow = row => {
createdTime: Number(content.created_time || row.created_time || 0),
updatedTime: Number(row.jop_updated_time || content.updated_time || 0),
ownerId: row.owner_id || '',
- shareId: content.share_id || '',
- isShared: !!(Number(content.is_shared || 0)),
+ shareId: shareIdOfRow(row, content),
+ isShared: !!(Number(content.is_shared || 0)) || !!shareIdOfRow(row, content),
};
};
@@ -110,8 +118,8 @@ const mapNoteHeaderRow = row => {
deletedTime: Number(row.deleted_time || 0),
updatedTime: Number(row.jop_updated_time || 0),
ownerId: row.owner_id || '',
- shareId: row.share_id || '',
- isShared: !!Number(row.is_shared || 0),
+ shareId: `${row.jop_share_id || row.share_id || ''}`,
+ isShared: !!Number(row.is_shared || 0) || !!(row.jop_share_id || row.share_id),
};
};
@@ -176,7 +184,7 @@ const createItemService = database => {
return {
async foldersByUserId(userId) {
const result = await database.query(`
- SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content, owner_id, jop_encryption_applied
+ SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content, owner_id, jop_encryption_applied, jop_share_id
FROM items
WHERE jop_type = $2${deletedFilterSql('exclude')} AND ${itemAccessExpression()}
ORDER BY LOWER(COALESCE(convert_from(content, 'UTF8')::json->>'title', '')) ASC, created_time ASC
@@ -187,7 +195,7 @@ const createItemService = database => {
async folderByUserIdAndJopId(userId, folderId) {
const result = await database.query(`
- SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content, owner_id, jop_encryption_applied
+ SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content, owner_id, jop_encryption_applied, jop_share_id
FROM items
WHERE jop_type = $2 AND jop_id = $3 AND ${itemAccessExpression()}
LIMIT 1
@@ -210,7 +218,7 @@ const createItemService = database => {
}
const result = await database.query(`
- SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content, owner_id, jop_encryption_applied
+ SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content, owner_id, jop_encryption_applied, jop_share_id
FROM items
${where}
ORDER BY jop_updated_time DESC, created_time DESC
@@ -228,6 +236,7 @@ const createItemService = database => {
jop_updated_time,
owner_id,
jop_encryption_applied,
+ jop_share_id,
COALESCE(convert_from(content, 'UTF8')::json->>'title', '') AS title,
COALESCE((convert_from(content, 'UTF8')::json->>'deleted_time')::bigint, 0) AS deleted_time,
COALESCE(convert_from(content, 'UTF8')::json->>'share_id', '') AS share_id,
@@ -296,6 +305,7 @@ const createItemService = database => {
jop_updated_time,
owner_id,
jop_encryption_applied,
+ jop_share_id,
COALESCE(convert_from(content, 'UTF8')::json->>'title', '') AS title,
COALESCE((convert_from(content, 'UTF8')::json->>'deleted_time')::bigint, 0) AS deleted_time,
COALESCE(convert_from(content, 'UTF8')::json->>'share_id', '') AS share_id,
@@ -315,9 +325,9 @@ const createItemService = database => {
if (!termSql) return [];
const limitIdx = 2 + termParams.length + 1;
const result = await database.query(`
- SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content, owner_id, jop_encryption_applied
+ SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content, owner_id, jop_encryption_applied, jop_share_id
FROM (
- SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content, owner_id, jop_encryption_applied,
+ SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content, owner_id, jop_encryption_applied, jop_share_id,
${safeJsonExpression("convert_from(content, 'UTF8')")} AS parsed
FROM items
WHERE jop_type = $2 AND ${itemAccessExpression()}
@@ -335,7 +345,7 @@ const createItemService = database => {
async noteByUserIdAndJopId(userId, noteId, options = {}) {
const deleted = options.deleted || 'exclude';
const result = await database.query(`
- SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content, owner_id, jop_encryption_applied
+ SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content, owner_id, jop_encryption_applied, jop_share_id
FROM items
WHERE jop_type = $2 AND jop_id = $3${deletedFilterSql(deleted)} AND ${itemAccessExpression()}
LIMIT 1
diff --git a/app/items/itemWriteService.js b/app/items/itemWriteService.js
index ca0d11d..c098808 100644
--- a/app/items/itemWriteService.js
+++ b/app/items/itemWriteService.js
@@ -150,7 +150,7 @@ const serializeResource = resource => {
ocr_details: '',
ocr_status: 0,
ocr_error: '',
- ocr_driver_id: 0,
+ ocr_driver_id: 1, // Joplin's default (PrintedText); 0 is a legacy quirk it only tolerates
},
overrides,
}),
diff --git a/app/routes/shares.js b/app/routes/shares.js
index 6ee1272..f5aaeea 100644
--- a/app/routes/shares.js
+++ b/app/routes/shares.js
@@ -48,16 +48,21 @@ const inviteesList = data => {
const shareFolderId = share => share && (share.folder_id || share.notebook_id || share.folderId || share.notebookId || '');
-// `share_id` of an item, decoded from its JSON content.
+// `share_id` of an item.
+//
+// The authoritative value is the `jop_share_id` COLUMN: Joplin Server moves share_id
+// out of the item JSON on every API save, so the JSON copy is absent or stale for any
+// item that has been edited. The JSON is only a fallback for items stamped directly in
+// the database.
//
// `items` also holds binary resource blobs (jop_type 0, e.g. every uploaded
// image/PDF). Those are not valid UTF-8, so convert_from() raises
-// "invalid byte sequence" and aborts the WHOLE statement — and SQL gives no
+// "invalid byte sequence" and aborts the WHOLE statement - and SQL gives no
// guarantee that a sibling `jop_type = ...` predicate runs first. The CASE
// guarantees only note/folder rows are ever decoded. (With an unguarded
// expression every share fan-out / revoke silently did nothing once the
// account owned a single uploaded attachment: the callers swallow errors.)
-const shareIdOf = (alias = '') => `(CASE WHEN ${alias}jop_type IN (1, 2) THEN convert_from(${alias}content, 'UTF8')::json->>'share_id' END)`;
+const shareIdOf = (alias = '') => `(COALESCE(NULLIF(${alias}jop_share_id, ''), CASE WHEN ${alias}jop_type IN (1, 2) THEN convert_from(${alias}content, 'UTF8')::json->>'share_id' END))`;
const newId = () => randomBytes(16).toString('hex');
@@ -201,24 +206,59 @@ const revokeRecipientAccess = async (database, shareId, itemIds) => {
// a database (unit tests with fakes).
// shareId the share the items now belong to ('' = none)
// previousShareId the share they belonged to before ('' = none)
-const createShareSync = ({ itemService, database }) => ({
- // A notebook and everything beneath it changes share.
- async setSubtreeShare({ ownerId, folderId, shareId = '', previousShareId = '' }) {
- if (!database) return;
- const { folderIds, noteIds } = await collectSubtreeItems(itemService, database, ownerId, folderId);
- const ids = folderIds.concat(noteIds);
- await setShareOnItems(database, ownerId, ids, shareId);
- if (previousShareId && previousShareId !== shareId) await revokeRecipientAccess(database, previousShareId, ids);
- if (shareId) await grantRecipientAccess(database, ownerId, shareId, folderId);
- },
- // Individual items (e.g. notes moved out of a deleted notebook) change share.
- async setItemsShare({ ownerId, itemIds, shareId = '', previousShareId = '', rootFolderId = '' }) {
- if (!database || !itemIds || !itemIds.length) return;
- await setShareOnItems(database, ownerId, itemIds, shareId);
- if (previousShareId && previousShareId !== shareId) await revokeRecipientAccess(database, previousShareId, itemIds);
- if (shareId) await grantRecipientAccess(database, ownerId, shareId, rootFolderId);
- },
-});
+const createShareSync = ({ itemService, itemWriteService, database }) => {
+ // Re-save every notebook and note of the subtree THROUGH THE API with the new share
+ // fields. This is what makes stock Joplin clients follow along: Joplin Server only
+ // adds/removes a recipient's copy (and tells their client to delete it) when it
+ // sees a change event with previous_share_id, and only API writes create those.
+ // Direct database writes are silent, so a subtree moved OUT of a share used to
+ // stay in the recipient's Joplin app. Items the API cannot rewrite (Joplin
+ // end-to-end encrypted ones) are left to the database stamping below.
+ const stampThroughApi = async ({ ownerId, folderIds, noteIds, shareId, sessionId, requestContext }) => {
+ if (!itemWriteService || !sessionId) return;
+ const fields = { shareId, isShared: !!shareId };
+ const skipped = [];
+ for (const id of folderIds) {
+ const folder = await itemService.folderByUserIdAndJopId(ownerId, id).catch(() => null);
+ if (!folder || folder.e2ee || (folder.shareId || '') === shareId) continue;
+ try { await itemWriteService.updateFolder(sessionId, folder, fields, requestContext); } catch (error) { skipped.push(error); }
+ }
+ for (const id of noteIds) {
+ const note = await itemService.noteByUserIdAndJopId(ownerId, id, { deleted: 'all' }).catch(() => null);
+ if (!note || note.e2ee || (note.shareId || '') === shareId) continue;
+ try { await itemWriteService.updateNote(sessionId, note, fields, requestContext); } catch (error) { skipped.push(error); }
+ }
+ if (skipped.length) throw skipped[0];
+ };
+
+ return {
+ // A notebook and everything beneath it changes share.
+ async setSubtreeShare({ ownerId, folderId, shareId = '', previousShareId = '', sessionId, requestContext }) {
+ if (!database) return;
+ const { folderIds, noteIds } = await collectSubtreeItems(itemService, database, ownerId, folderId);
+ const ids = folderIds.concat(noteIds);
+ // API first (it compares against the current share), then the database sweep
+ // for anything the API could not write, then Joplock's own recipient rows.
+ let apiError = null;
+ try {
+ await stampThroughApi({ ownerId, folderIds, noteIds, shareId, sessionId, requestContext });
+ } catch (error) { apiError = error; }
+ await setShareOnItems(database, ownerId, ids, shareId);
+ if (previousShareId && previousShareId !== shareId) await revokeRecipientAccess(database, previousShareId, ids);
+ if (shareId) await grantRecipientAccess(database, ownerId, shareId, folderId);
+ if (apiError) throw apiError;
+ },
+ // Individual items (e.g. notes moved out of a deleted notebook) change share. The
+ // caller has already written them through the API; this keeps Joplock's own
+ // recipient rows and the stored JSON in step.
+ async setItemsShare({ ownerId, itemIds, shareId = '', previousShareId = '', rootFolderId = '' }) {
+ if (!database || !itemIds || !itemIds.length) return;
+ await setShareOnItems(database, ownerId, itemIds, shareId);
+ if (previousShareId && previousShareId !== shareId) await revokeRecipientAccess(database, previousShareId, itemIds);
+ if (shareId) await grantRecipientAccess(database, ownerId, shareId, rootFolderId);
+ },
+ };
+};
const createShareUpstream = async (ctx, sessionId, notebookId) => {
// Joplin Server has used both folder_id and notebook_id historically.
diff --git a/app/templates/pages.js b/app/templates/pages.js
index d1f0560..241fcdc 100644
--- a/app/templates/pages.js
+++ b/app/templates/pages.js
@@ -10,7 +10,7 @@ const {
} = require('./shared');
const { noteMetaFragment } = require('./fragments');
-const ASSET_VERSION = '20261008nestedfolders';
+const ASSET_VERSION = '20261009joplinfidelity';
const noteFontFamilyCSS = (settings) => {
const f = settings.noteFontFamily || 'sans';
diff --git a/integration-tests/joplin-guards.test.js b/integration-tests/joplin-guards.test.js
new file mode 100644
index 0000000..e02809c
--- /dev/null
+++ b/integration-tests/joplin-guards.test.js
@@ -0,0 +1,217 @@
+'use strict';
+
+// Items Joplock must never mangle in a Joplin account: HTML notes, end-to-end
+// encrypted items and conflict copies - plus attachments, which the client must
+// be able to download like any other resource.
+
+const { describe, it, before, after } = require('node:test');
+const assert = require('node:assert/strict');
+const crypto = require('node:crypto');
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const { skipReason, sleep, JoplinClient, connectAccount } = require('./joplin/harness');
+
+const skip = skipReason();
+const PREFIX = `jc-grd-${Date.now().toString(36)}`;
+const id32 = () => crypto.randomBytes(16).toString('hex');
+const TS = '2026-01-02T03:04:05.678Z';
+
+// A plain note as a stock client would upload it.
+const rawNote = (id, parent, title, body, extra = []) => [
+ title, '', body, '',
+ `id: ${id}`, `parent_id: ${parent}`, `created_time: ${TS}`, `updated_time: ${TS}`,
+ 'is_conflict: 0', 'latitude: 0.00000000', 'longitude: 0.00000000', 'altitude: 0.0000', 'author: ', 'source_url: ',
+ 'is_todo: 0', 'todo_due: 0', 'todo_completed: 0', 'source: joplin-desktop', 'source_application: net.cozic.joplin-desktop',
+ 'application_data: ', 'order: 1767323045678', `user_created_time: ${TS}`, `user_updated_time: ${TS}`,
+ 'encryption_cipher_text: ', 'encryption_applied: 0', 'markup_language: 1', 'is_shared: 0', 'share_id: ',
+ 'conflict_original_id: ', 'master_key_id: ', 'user_data: ', 'deleted_time: 0', ...extra, 'type_: 1',
+].join('\n');
+
+describe('HTML notes', { skip, concurrency: false }, () => {
+ let jp; let client;
+ before(async () => { jp = await connectAccount(); client = await new JoplinClient('html').init(); });
+ after(async () => { if (client) client.close(); if (jp) await jp.cleanup(PREFIX); });
+
+ it('are rendered sanitized and read-only; content edits are refused, moves are not', async () => {
+ const book = `${PREFIX}-html`;
+ const title = `${PREFIX}-clip`;
+ const html = '
Clipped page
bad';
+ await client.run('mkbook', book);
+ await client.run('use', book);
+ await client.run('mknote', title);
+ await client.run('set', title, 'markup_language', '2');
+ await client.run('set', title, 'body', html);
+ await client.sync();
+ const n = await client.note(title);
+ assert.equal(n.markup_language, 2);
+
+ // Joplock shows it rendered and read-only, with the dangerous bits gone.
+ const ed = await jp.ok('GET', `/fragments/editor/${n.id}`);
+ assert.match(ed.text, /data-html-note="1"/);
+ assert.match(ed.text, /HTML note/);
+ const slot = /data-rendered-body="([^"]*)"/.exec(ed.text);
+ assert.ok(slot, 'rendered body present');
+ const rendered = slot[1].replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/'/g, "'");
+ assert.match(rendered, /
overwritten
' }); + assert.equal(refused.status, 403, refused.text); + assert.equal(await jp.rawItem(n.id), before, 'item on the server is byte-identical'); + + // The real autosave path (form PUT) for a MOVE of an HTML note: allowed, body kept. + const dest = await jp.createFolder(`${PREFIX}-html-dest`); + const moved = await jp.request('PUT', `/fragments/editor/${n.id}`, { + form: { title, body: n.body, parentId: dest, currentFolderId: dest }, + }); + assert.equal(moved.status, 200, moved.text); + await sleep(300); + await client.sync(); + const after = await client.note(title); + assert.equal(after.parent_id, dest); + assert.equal(after.markup_language, 2); + assert.equal(after.body, html, 'HTML body byte-for-byte'); + }); +}); + +describe('Joplin end-to-end encrypted items', { skip, concurrency: false }, () => { + let jp; + before(async () => { jp = await connectAccount(); }); + after(async () => { if (jp) await jp.cleanup(PREFIX); }); + + // What a client uploads for an encrypted item: ids, parent and the ciphertext only. + const encryptedItem = (type, id, parent) => [ + `id: ${id}`, ...(type === 1 ? [`parent_id: ${parent}`] : []), + 'encryption_cipher_text: JED01000007fakeciphertextfakeciphertextfakeciphertext', + 'encryption_applied: 1', + `type_: ${type}`, + ].join('\n'); + + it('notes show as a locked placeholder and can never be changed from Joplock', async () => { + const folder = await jp.createFolder(`${PREFIX}-e2ee`); + const id = id32(); + await jp.putRawItem(id, encryptedItem(1, id, folder)); + const before = await jp.rawItem(id); + assert.match(before, /encryption_applied: 1/); + + const headers = await jp.noteHeaders(); + const h = headers.find(x => x.id === id); + assert.ok(h, 'listed (not silently dropped)'); + assert.match(h.title, /Encrypted note/); + + const ed = await jp.ok('GET', `/fragments/editor/${id}`); + assert.match(ed.text, /editor-e2ee/); + assert.doesNotMatch(ed.text, /