diff --git a/assets/index.html b/assets/index.html
index 462c3a0..c3ad4b7 100644
--- a/assets/index.html
+++ b/assets/index.html
@@ -1,5 +1,5 @@
-
+
@@ -884,6 +884,28 @@ Markdown Preview
let sseConnected = false;
const loadedPacks = new Set();
+ // Live-reload auth token. Either substituted into the document by
+ // markdown-preview.nvim at file-write time, or passed via ?t=
+ // in the URL on first load (then stashed in sessionStorage so it
+ // survives refreshes). Empty string when the server has no auth.
+ const LIVE_TOKEN = (() => {
+ const fromAttr = document.documentElement.dataset.liveToken || '';
+ if (fromAttr && fromAttr !== '__LIVE_TOKEN__') {
+ try { sessionStorage.setItem('mdp-token', fromAttr); } catch (_) {}
+ return fromAttr;
+ }
+ const fromUrl = new URLSearchParams(location.search).get('t');
+ if (fromUrl) {
+ try { sessionStorage.setItem('mdp-token', fromUrl); } catch (_) {}
+ return fromUrl;
+ }
+ try { return sessionStorage.getItem('mdp-token') || ''; } catch (_) { return ''; }
+ })();
+ const withToken = (url) => {
+ if (!LIVE_TOKEN) return url;
+ return url + (url.indexOf('?') >= 0 ? '&' : '?') + 't=' + encodeURIComponent(LIVE_TOKEN);
+ };
+
// Scroll sync state
let scrollSyncPaused = false;
let scrollSyncPauseTimer = null;
@@ -1131,7 +1153,7 @@ Markdown Preview
// ── Content sync ──────────────────────────────────────────────
async function fetchContent() {
try {
- const resp = await fetch('content.md?ts=' + Date.now(), { cache: 'no-store' });
+ const resp = await fetch(withToken('content.md?ts=' + Date.now()), { cache: 'no-store' });
if (!resp.ok) {
console.warn('[markdown-preview] content.md fetch failed:', resp.status);
return null;
@@ -1258,7 +1280,7 @@ Markdown Preview
// ── SSE (Server-Sent Events from live-server.nvim) ────────────
function connectSSE() {
- const evtSource = new EventSource('/__live/events');
+ const evtSource = new EventSource(withToken('/__live/events'));
evtSource.addEventListener('reload', async () => {
await sync(false);
});
diff --git a/lua/markdown_preview/init.lua b/lua/markdown_preview/init.lua
index f17e5f2..c149748 100644
--- a/lua/markdown_preview/init.lua
+++ b/lua/markdown_preview/init.lua
@@ -2,6 +2,7 @@
local ts = require("markdown_preview.ts")
local util = require("markdown_preview.util")
local ls_server = require("live_server.server")
+local ls_util = require("live_server.util")
local M = {}
@@ -63,6 +64,7 @@ M._mmdr_available = nil -- nil = unchecked, true/false after probe
M._last_scroll_line = nil
M._is_primary = nil -- true/false/nil (takeover mode)
M._takeover_port = nil -- port of primary server (secondary uses for HTTP events)
+M._token = nil -- live-server auth token (primary owns; secondaries read from lockfile)
local function effective_port()
if M.config.port ~= 0 then return M.config.port end
@@ -98,8 +100,11 @@ local function write_index(dir)
error("Could not locate assets/index.html in runtimepath. Make sure the plugin ships it.")
end
local content = util.read_text(src)
- content = content:gsub("__BOTTOM_PADDING__", tostring(M.config.bottom_padding))
- content = content:gsub("__MERMAID_ELK__", M.config.mermaid_elk and "true" or "false")
+ -- gsub with function replacement: avoids the "%n is a capture reference"
+ -- escape problem if any substituted value contains '%'.
+ content = content:gsub("__BOTTOM_PADDING__", function() return tostring(M.config.bottom_padding) end)
+ content = content:gsub("__MERMAID_ELK__", function() return M.config.mermaid_elk and "true" or "false" end)
+ content = content:gsub("__LIVE_TOKEN__", function() return M._token or "" end)
util.write_text(dst, content)
return dst
end
@@ -364,7 +369,7 @@ local function send_scroll_sync(bufnr)
if M._server_instance then
pcall(ls_server.send_event, M._server_instance, "scroll", payload)
elseif M._takeover_port then
- require("markdown_preview.remote").send_event(M._takeover_port, "scroll", payload)
+ require("markdown_preview.remote").send_event(M._takeover_port, "scroll", payload, M._token)
end
end
@@ -405,6 +410,17 @@ end
-- Public API
---------------------------------------------------------------------------
+-- Build the URL the browser opens to. Embeds the auth token when one exists
+-- so the first request includes it (the page then stashes it in
+-- sessionStorage for refreshes).
+local function browser_url(port)
+ local base = ("http://127.0.0.1:%d/"):format(port)
+ if M._token and M._token ~= "" then
+ return base .. "?t=" .. M._token
+ end
+ return base
+end
+
function M.start()
local bufnr = vim.api.nvim_get_current_buf()
M._active_bufnr = bufnr
@@ -425,26 +441,43 @@ function M.start()
util.mkdirp(dir)
M._workspace_dir = dir
- write_index_if_needed(dir)
- write_content(dir, text)
- M._last_text_by_buf[bufnr] = text
-
- set_autocmds_for_buffer(bufnr)
-
- -- In takeover mode, check if another instance already owns the server
+ -- Decide role + token BEFORE writing index.html. The index bakes the
+ -- token in via the __LIVE_TOKEN__ placeholder, so we need it ready.
if M.config.instance_mode == "takeover" and not M._server_instance then
local lock = require("markdown_preview.lock")
local lock_data = lock.read()
if lock_data and lock.is_server_alive(lock_data.port) then
- -- Secondary mode: server already running in another Neovim instance
+ -- Secondary: server is already running in another Neovim
+ -- instance. Adopt its token so our scroll-sync RPC works.
M._is_primary = false
M._takeover_port = lock_data.port
+ M._token = lock_data.token
+ write_content(dir, text)
+ M._last_text_by_buf[bufnr] = text
+ set_autocmds_for_buffer(bufnr)
return
end
- -- Stale lock or no lock — we become primary
+ -- Stale lock or no lock, we become primary
lock.remove()
end
+ -- Primary path (takeover) or single-instance (multi). Generate a token
+ -- once per server lifetime and reuse it across retargets.
+ if not M._token or M._token == "" then
+ M._token = ls_util.random_token(16)
+ end
+
+ write_index_if_needed(dir)
+ write_content(dir, text)
+ M._last_text_by_buf[bufnr] = text
+
+ set_autocmds_for_buffer(bufnr)
+
+ -- Pattern matching the workspace-served content file. Used by live-server
+ -- to require ?t= on requests for it. Escape any '.' in the
+ -- configured content_name so it's a literal match.
+ local content_path_pattern = "^/" .. M.config.content_name:gsub("%.", "%%.") .. "$"
+
-- Start live-server if not already running
if not M._server_instance then
local port = effective_port()
@@ -461,6 +494,8 @@ function M.start()
debounce = 100,
},
features = { dirlist = { enabled = false } },
+ token = M._token,
+ protected_paths = { content_path_pattern },
})
if not ok then
vim.notify(
@@ -475,16 +510,16 @@ function M.start()
-- Write lock file in takeover mode
if M.config.instance_mode == "takeover" then
- require("markdown_preview.lock").write(inst.port, dir)
+ require("markdown_preview.lock").write(inst.port, dir, M._token)
end
if M.config.open_browser then
vim.defer_fn(function()
- util.open_in_browser(("http://127.0.0.1:%d/"):format(inst.port), M.config.browser)
+ util.open_in_browser(browser_url(inst.port), M.config.browser)
end, 200)
end
else
- -- Server already running — retarget to this buffer's workspace
+ -- Server already running, retarget to this buffer's workspace
local index_path = vim.fs.joinpath(dir, M.config.index_name)
pcall(ls_server.update_target, M._server_instance, dir, index_path)
pcall(ls_server.reload, M._server_instance, M.config.content_name)
@@ -492,7 +527,7 @@ function M.start()
-- No browser tab connected (user closed it)? Re-open.
if M.config.open_browser and ls_server.connected_client_count(M._server_instance) == 0 then
vim.defer_fn(function()
- util.open_in_browser(("http://127.0.0.1:%d/"):format(M._server_instance.port), M.config.browser)
+ util.open_in_browser(browser_url(M._server_instance.port), M.config.browser)
end, 200)
end
end
@@ -522,6 +557,7 @@ function M.stop()
M._last_scroll_line = nil
M._is_primary = nil
M._takeover_port = nil
+ M._token = nil
end
return M
diff --git a/lua/markdown_preview/lock.lua b/lua/markdown_preview/lock.lua
index 29dca60..62e1b77 100644
--- a/lua/markdown_preview/lock.lua
+++ b/lua/markdown_preview/lock.lua
@@ -21,14 +21,22 @@ function M.read()
return tbl
end
-function M.write(port, workspace)
+function M.write(port, workspace, token)
local path = lock_path()
local dir = path:match("^(.+)/[^/]+$")
if dir and vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, "p")
end
- local json = vim.json.encode({ port = port, workspace = workspace, pid = vim.fn.getpid() })
- local fd = assert(uv.fs_open(path, "w", 420))
+ local json = vim.json.encode({
+ port = port,
+ workspace = workspace,
+ pid = vim.fn.getpid(),
+ token = token, -- nil OK; secondary instances need this to hit /__live/inject
+ })
+ -- Mode 0600 (decimal 384) so the token isn't world-readable on multi-user
+ -- systems. Use fs_open + immediate truncate so older lockfiles with looser
+ -- modes get replaced cleanly.
+ local fd = assert(uv.fs_open(path, "w", 384))
assert(uv.fs_write(fd, json, 0))
assert(uv.fs_close(fd))
end
diff --git a/lua/markdown_preview/remote.lua b/lua/markdown_preview/remote.lua
index d526a09..34b8de4 100644
--- a/lua/markdown_preview/remote.lua
+++ b/lua/markdown_preview/remote.lua
@@ -3,12 +3,16 @@ local uv = vim.loop
local M = {}
-function M.send_event(port, event_type, json_data)
+function M.send_event(port, event_type, json_data, token)
local tcp = uv.new_tcp()
local encoded = vim.uri_encode(json_data)
+ local query = string.format("event=%s&data=%s", event_type, encoded)
+ if token and token ~= "" then
+ query = query .. "&t=" .. vim.uri_encode(token)
+ end
local req = string.format(
- "GET /__live/inject?event=%s&data=%s HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
- event_type, encoded
+ "GET /__live/inject?%s HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
+ query
)
tcp:connect("127.0.0.1", port, function(err)
if err then pcall(function() tcp:close() end); return end
diff --git a/tests/token_auth_test.lua b/tests/token_auth_test.lua
new file mode 100644
index 0000000..8e887f7
--- /dev/null
+++ b/tests/token_auth_test.lua
@@ -0,0 +1,89 @@
+-- tests/token_auth_test.lua
+-- End-to-end check that :MarkdownPreview generates a token, threads it into
+-- the served HTML, gates content.md, and that scroll-sync RPC carries it.
+--
+-- Run: nvim --headless -c "set rtp+=./live-server-rtp" -c "set rtp+=." \
+-- -c "luafile tests/token_auth_test.lua" -c "qa!"
+--
+-- The CI workflow shims live-server.nvim into ./live-server-rtp before run.
+
+local uv = vim.loop
+
+-- ─── Setup: open a markdown buffer ──────────────────────────────────────────
+local tmpdir = vim.fn.tempname()
+vim.fn.mkdir(tmpdir, "p")
+local mdfile = vim.fs.joinpath(tmpdir, "test.md")
+do
+ local fd = uv.fs_open(mdfile, "w", 420)
+ uv.fs_write(fd, "# hello\n\nbody text here.\n", 0)
+ uv.fs_close(fd)
+end
+
+vim.cmd("edit " .. mdfile)
+vim.bo.filetype = "markdown"
+
+-- ─── Configure: avoid opening a real browser, force multi mode for isolation
+local mp = require("markdown_preview")
+mp.setup({
+ open_browser = false,
+ instance_mode = "multi",
+})
+
+-- ─── Start ──────────────────────────────────────────────────────────────────
+mp.start()
+
+local passed, failed = 0, 0
+local function ok(cond, msg)
+ if cond then
+ passed = passed + 1
+ print(" PASS: " .. msg)
+ else
+ failed = failed + 1
+ print(" FAIL: " .. msg)
+ end
+end
+
+-- Server instance + token must exist
+ok(mp._server_instance ~= nil, "server instance created")
+ok(type(mp._token) == "string" and #mp._token == 32, "_token is 32 hex chars")
+ok(mp._token:match("^[0-9a-f]+$") ~= nil, "_token is pure hex")
+
+local port = mp._server_instance.port
+ok(type(port) == "number" and port > 0, "server bound to a port")
+
+-- ─── HTTP curl helper ───────────────────────────────────────────────────────
+local function http_get(url)
+ local out = vim.fn.system({ "curl", "-s", "-o", "-", "-w", "\nHTTPSTATUS:%{http_code}", url })
+ local body, status = out:match("^(.*)\nHTTPSTATUS:(%d+)%s*$")
+ return { status = tonumber(status), body = body or "" }
+end
+
+-- Static index reachable without token
+local r = http_get(("http://127.0.0.1:%d/"):format(port))
+ok(r.status == 200, "/ (index) is 200 without token")
+ok(r.body:find("data%-live%-token=\"" .. mp._token .. "\"") ~= nil,
+ "index.html has data-live-token attribute set to current token")
+
+-- content.md is gated
+r = http_get(("http://127.0.0.1:%d/content.md"):format(port))
+ok(r.status == 401, "/content.md without token is 401")
+
+r = http_get(("http://127.0.0.1:%d/content.md?t=%s"):format(port, mp._token))
+ok(r.status == 200, "/content.md with correct token is 200")
+ok(r.body:find("hello") ~= nil, "/content.md body contains buffer text")
+
+-- ─── Stop and verify cleanup ────────────────────────────────────────────────
+mp.stop()
+ok(mp._token == nil, "_token cleared after stop")
+ok(mp._server_instance == nil, "_server_instance cleared after stop")
+
+-- Port no longer accepts connections (give it a moment)
+vim.wait(200, function() return false end)
+r = http_get(("http://127.0.0.1:%d/"):format(port))
+ok(r.status == nil or r.status == 0, "port no longer responds after stop (status=" .. tostring(r.status) .. ")")
+
+print(string.format("\n========================================"))
+print(string.format("Results: %d passed, %d failed", passed, failed))
+print(string.format("========================================"))
+
+if failed > 0 then vim.cmd("cq 1") end