Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
122a5f0
feat: add version history browser (CE3)
Feb 14, 2026
aba18b1
fix: address PR #4 review feedback — 16 comments
Feb 14, 2026
c881431
fix: address PR #4 round-2 review — 5 issues
Feb 14, 2026
3c61794
fix: reset history panel state when switching to new article
Feb 14, 2026
4b4b54d
fix: guard decoded.trailers destructuring in unpublish and revert
Feb 14, 2026
b978367
fix: guard readVersion return trailers with || {}
Feb 14, 2026
916eff5
fix: prevent temp directory leak on upload failure
Feb 14, 2026
1f043e1
chore: update CHANGELOG and bump to v1.0.1
Feb 14, 2026
1e94583
fix: sanitize 500 error responses to prevent internal detail leakage
Feb 14, 2026
f59c79d
perf: use Buffer.concat in readBody instead of string concatenation
Feb 14, 2026
87bbc73
fix: reset stale history state unconditionally in loadArticle
Feb 14, 2026
1e4b845
fix: guard selectVersion against stale async responses
Feb 14, 2026
1d74221
refactor: extract HISTORY_WALK_LIMIT as shared constant
Feb 14, 2026
2b812fd
docs: add inline comment explaining trailer-codec lowercase normaliza…
Feb 14, 2026
63a9f9a
chore: update CHANGELOG and bump to v1.0.2
Feb 14, 2026
90ba0a9
test: add regression test for sendError 500 response sanitization
Feb 14, 2026
966b7b3
fix: clear history DOM alongside state reset in loadArticle
Feb 14, 2026
c086e0c
fix: prevent autosave race and double-prompt in restoreVersion
Feb 14, 2026
806784b
fix: clamp limit internally in getArticleHistory
Feb 14, 2026
f1ab75e
fix: use NaN check instead of || for history limit parsing
Feb 14, 2026
3154631
test: assert response status before calling .json() in server tests
Feb 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ All notable changes to git-cms are documented in this file.

### Added

- **Version History Browser (CE3):** Browse prior versions of an article, preview old content, and restore a selected version as a new draft commit
- `CmsService.getArticleHistory()` — walk parent chain to list version summaries (SHA, title, status, author, date)
- `CmsService.readVersion()` — read full content of a specific commit by SHA
- `CmsService.restoreVersion()` — restore historical content as a new draft with ancestry validation and provenance trailers (`restoredFromSha`, `restoredAt`)
- `GET /api/cms/history`, `GET /api/cms/show-version`, `POST /api/cms/restore` server endpoints
- Admin UI: collapsible history panel with lazy-fetch, version preview, and restore button

- **Content Identity Policy (M1.1):** Canonical slug validation with NFKC normalization, reserved word rejection, and `CmsValidationError` contract (`ContentIdentityPolicy.js`)
- **State Machine (M1.2):** Explicit draft/published/unpublished/reverted states with enforced transition rules (`ContentStatePolicy.js`)
- **Admin UI overhaul:** Split/edit/preview markdown editor (via `marked`), autosave, toast notifications, skeleton loading, drag-and-drop file uploads, metadata trailer editor, keyboard shortcuts (`Cmd+S`, `Esc`), dark mode token system
Expand Down
173 changes: 173 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,50 @@
margin-top: var(--size-2);
}

/* ── History Section ── */
#historySection .history-list {
max-height: 240px;
overflow-y: auto;
margin-top: var(--size-2);
display: flex;
flex-direction: column;
gap: 2px;
}
.history-item {
display: flex;
align-items: center;
gap: var(--size-3);
padding: var(--size-2) var(--size-3);
border-radius: var(--radius-2);
cursor: pointer;
transition: background 0.15s;
font-size: var(--font-size-0);
}
.history-item:hover { background: var(--surface-3); }
.history-item.active { background: var(--brand); color: white; }
.history-item .hist-sha { font-family: var(--font-mono); font-size: var(--font-size-00); opacity: 0.7; }
.history-item .hist-title { flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-item .hist-date { font-size: var(--font-size-00); opacity: 0.7; white-space: nowrap; }
.history-item .hist-status { font-size: var(--font-size-00); opacity: 0.7; }

#historyPreview {
margin-top: var(--size-3);
border: 1px solid var(--surface-3);
border-radius: var(--radius-2);
padding: var(--size-3);
display: none;
}
#historyPreview .preview-content {
max-height: 300px;
overflow-y: auto;
line-height: 1.7;
}
#historyPreview .preview-actions {
margin-top: var(--size-3);
display: flex;
gap: var(--size-2);
}

/* ── Status Bar ── */
.status-bar {
font-size: var(--font-size-0);
Expand Down Expand Up @@ -457,6 +501,17 @@ <h1>Git CMS</h1>
<button class="btn add-trailer-btn" onclick="UI.addTrailerRow()">+ Add Field</button>
</details>

<details id="historySection">
<summary>Version History</summary>
<div id="historyList" class="history-list"></div>
<div id="historyPreview">
<div id="historyPreviewContent" class="preview-content"></div>
<div class="preview-actions">
<button class="btn btn-primary" id="restoreBtn" onclick="UI.restoreVersion()" disabled>Restore This Version</button>
</div>
</div>
</details>

<div class="asset-section" id="dropZone">
Drop files here or
<label class="btn" style="margin-left:var(--size-2)">
Expand Down Expand Up @@ -490,6 +545,8 @@ <h1>Git CMS</h1>
autosaveTimer: null,
editorMode: 'split',
trailers: {},
historyVersions: [],
selectedVersion: null,
};

/* ── API Layer ── */
Expand Down Expand Up @@ -537,6 +594,29 @@ <h1>Git CMS</h1>
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
return res.json();
},

async history(slug, limit = 50) {
const res = await fetch(`${API_BASE}/history?slug=${encodeURIComponent(slug)}&limit=${limit}`);
if (!res.ok) throw new Error(`History failed: ${res.status}`);
return res.json();
},

async showVersion(slug, sha) {
const res = await fetch(`${API_BASE}/show-version?slug=${encodeURIComponent(slug)}&sha=${encodeURIComponent(sha)}`);
if (!res.ok) throw new Error(`Show version failed: ${res.status}`);
return res.json();
},

async restore({ slug, sha }) {
const res = await fetch(`${API_BASE}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, sha }),
});
const data = await res.json();
if (!res.ok) throw Object.assign(new Error(data.error || `Restore failed: ${res.status}`), { code: data.code });
return data;
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

/* ── Toast ── */
Expand Down Expand Up @@ -903,6 +983,94 @@ <h1>Git CMS</h1>
this.hideEditor();
},

/* Version History */
async fetchHistory() {
if (!state.currentSlug) return;
const listEl = document.getElementById('historyList');
listEl.innerHTML = '<div class="skeleton" style="height:1.8em"></div>';
document.getElementById('historyPreview').style.display = 'none';
state.selectedVersion = null;

try {
const versions = await api.history(state.currentSlug);
state.historyVersions = versions;
listEl.innerHTML = '';

versions.forEach((v, idx) => {
const div = document.createElement('div');
div.className = 'history-item';
div.dataset.idx = idx;

const shaSpan = document.createElement('span');
shaSpan.className = 'hist-sha';
shaSpan.textContent = v.sha.slice(0, 7);

const titleSpan = document.createElement('span');
titleSpan.className = 'hist-title';
titleSpan.textContent = v.title;

const statusSpan = document.createElement('span');
statusSpan.className = 'hist-status';
statusSpan.textContent = v.status;

const dateSpan = document.createElement('span');
dateSpan.className = 'hist-date';
dateSpan.textContent = relTime(v.date);

div.append(shaSpan, titleSpan, statusSpan, dateSpan);
div.onclick = () => this.selectVersion(v.sha, idx);
listEl.appendChild(div);
});
} catch (err) {
toast('Failed to load history', 'error');
listEl.innerHTML = '';
}
},
Comment on lines +1005 to +1047
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

fetchHistory has no staleness guard — late-arriving response from a previous article silently overwrites the current article's history.

You correctly added a staleness guard in selectVersion (line 1064: if (state.selectedVersion?.sha !== sha) return), but fetchHistory has no equivalent. Scenario: panel is open, user clicks article A → fetchHistory fires for A → user quickly clicks article B → loadArticle resets state, fires fetchHistory for B → A's response arrives after B's reset but before B's response → A's versions are rendered and assigned to state.historyVersions for article B.

🐛 Proposed fix: capture slug at call time, bail if stale
       async fetchHistory() {
         if (!state.currentSlug) return;
+        const slug = state.currentSlug;
         const listEl = document.getElementById('historyList');
         listEl.innerHTML = '<div class="skeleton" style="height:1.8em"></div>';
         document.getElementById('historyPreview').style.display = 'none';
         state.selectedVersion = null;

         try {
-          const versions = await api.history(state.currentSlug);
+          const versions = await api.history(slug);
+          // Guard: article may have changed during fetch
+          if (state.currentSlug !== slug) return;
           state.historyVersions = versions;
           listEl.innerHTML = '';

           versions.forEach((v, idx) => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/* Version History */
async fetchHistory() {
if (!state.currentSlug) return;
const listEl = document.getElementById('historyList');
listEl.innerHTML = '<div class="skeleton" style="height:1.8em"></div>';
document.getElementById('historyPreview').style.display = 'none';
state.selectedVersion = null;
try {
const versions = await api.history(state.currentSlug);
state.historyVersions = versions;
listEl.innerHTML = '';
versions.forEach((v, idx) => {
const div = document.createElement('div');
div.className = 'history-item';
div.dataset.idx = idx;
const shaSpan = document.createElement('span');
shaSpan.className = 'hist-sha';
shaSpan.textContent = v.sha.slice(0, 7);
const titleSpan = document.createElement('span');
titleSpan.className = 'hist-title';
titleSpan.textContent = v.title;
const statusSpan = document.createElement('span');
statusSpan.className = 'hist-status';
statusSpan.textContent = v.status;
const dateSpan = document.createElement('span');
dateSpan.className = 'hist-date';
dateSpan.textContent = relTime(v.date);
div.append(shaSpan, titleSpan, statusSpan, dateSpan);
div.onclick = () => this.selectVersion(v.sha, idx);
listEl.appendChild(div);
});
} catch (err) {
toast('Failed to load history', 'error');
listEl.innerHTML = '';
}
},
/* Version History */
async fetchHistory() {
if (!state.currentSlug) return;
const slug = state.currentSlug;
const listEl = document.getElementById('historyList');
listEl.innerHTML = '<div class="skeleton" style="height:1.8em"></div>';
document.getElementById('historyPreview').style.display = 'none';
state.selectedVersion = null;
try {
const versions = await api.history(slug);
// Guard: article may have changed during fetch
if (state.currentSlug !== slug) return;
state.historyVersions = versions;
listEl.innerHTML = '';
versions.forEach((v, idx) => {
const div = document.createElement('div');
div.className = 'history-item';
div.dataset.idx = idx;
const shaSpan = document.createElement('span');
shaSpan.className = 'hist-sha';
shaSpan.textContent = v.sha.slice(0, 7);
const titleSpan = document.createElement('span');
titleSpan.className = 'hist-title';
titleSpan.textContent = v.title;
const statusSpan = document.createElement('span');
statusSpan.className = 'hist-status';
statusSpan.textContent = v.status;
const dateSpan = document.createElement('span');
dateSpan.className = 'hist-date';
dateSpan.textContent = relTime(v.date);
div.append(shaSpan, titleSpan, statusSpan, dateSpan);
div.onclick = () => this.selectVersion(v.sha, idx);
listEl.appendChild(div);
});
} catch (err) {
toast('Failed to load history', 'error');
listEl.innerHTML = '';
}
},
🤖 Prompt for AI Agents
In `@public/index.html` around lines 1005 - 1047, fetchHistory can be overwritten
by a late response from a previous slug; capture the slug at the start (e.g.
const slug = state.currentSlug) and after awaiting api.history(because versions
may return late) bail if state.currentSlug !== slug before mutating
state.historyVersions, rendering listEl, or touching state.selectedVersion;
implement the same staleness-guard pattern used in selectVersion so only the
most-recent fetch updates the DOM and state.


async selectVersion(sha, idx) {
state.selectedVersion = { sha, idx };
document.querySelectorAll('.history-item').forEach((el, i) => {
el.classList.toggle('active', i === idx);
});

const previewEl = document.getElementById('historyPreview');
const contentEl = document.getElementById('historyPreviewContent');
const restoreBtn = document.getElementById('restoreBtn');

previewEl.style.display = 'block';
contentEl.innerHTML = '<div class="skeleton" style="height:4em"></div>';

try {
const data = await api.showVersion(state.currentSlug, sha);
contentEl.innerHTML = DOMPurify.sanitize(marked.parse(data.body || ''));
// Disable restore for current version (idx 0)
restoreBtn.disabled = idx === 0;
} catch (err) {
contentEl.textContent = 'Failed to load version content';
restoreBtn.disabled = true;
}
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async restoreVersion() {
if (!state.selectedVersion || !state.currentSlug) return;
const { sha } = state.selectedVersion;
if (!confirm(`Restore version ${sha.slice(0, 7)}? This creates a new draft with the old content.`)) return;

try {
await api.restore({ slug: state.currentSlug, sha });
toast('Version restored', 'success');
// Close history section and reload article
document.getElementById('historySection').open = false;
await this.loadArticle(state.currentSlug);
} catch (err) {
if (err.code === 'invalid_state_transition') {
toast('Cannot restore: unpublish the article first', 'error');
} else {
toast('Restore failed: ' + err.message, 'error');
}
}
},
Comment on lines +1075 to +1098
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

restoreVersion races with the autosave timer — the timer is not cleared before the async restore call.

The state.saving guard at line 1076 blocks restore while a save is in-flight, but the reverse isn't protected. Between the confirm() returning and loadArticle completing (which calls clearAutosave), the 3-second autosave timer can fire. Since restoreVersion never sets state.saving = true, the autosave's save() call proceeds and races the restore — both attempt to CAS-update the same draft ref, and the loser gets a confusing CAS conflict error.

Additionally, if the user had unsaved edits, loadArticle (line 1085) prompts again with "You have unsaved changes. Discard them?" — right after they already confirmed the restore. If they cancel this second dialog, the editor shows stale pre-restore content while the server already has the restored version.

Fix both by clearing autosave and resetting dirty state before the async work:

Proposed fix
       async restoreVersion() {
         if (!state.selectedVersion || !state.currentSlug || state.saving) return;
         const { sha } = state.selectedVersion;
         if (!confirm(`Restore version ${sha.slice(0, 7)}? This creates a new draft with the old content.`)) return;

+        // Prevent autosave from racing the restore and suppress
+        // the redundant "unsaved changes" prompt in loadArticle.
+        UI.clearAutosave();
+        state.dirty = false;
+
         try {
           await api.restore({ slug: state.currentSlug, sha });
           toast('Version restored', 'success');
           document.getElementById('historySection').open = false;
           await this.loadArticle(state.currentSlug);
         } catch (err) {
🤖 Prompt for AI Agents
In `@public/index.html` around lines 1075 - 1093, In restoreVersion, before any
awaits, clear the autosave timer and reset the editor dirty/saving flags to
prevent the autosave save() from racing the restore and from re-prompting;
specifically, call clearAutosave() (the existing autosave cleanup) and set
state.dirty = false and set state.saving = true immediately after the confirm
and before calling api.restore({ slug: state.currentSlug, sha }), then in the
try/finally restore state.saving = false (and restart autosave as appropriate)
so loadArticle() won't trigger a discard prompt or collide with an in-flight
autosave.


escAttr(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/'/g, '&#39;');
},
Expand Down Expand Up @@ -967,6 +1135,11 @@ <h1>Git CMS</h1>
this.value = '';
});

// History section toggle — lazy-fetch on expand
document.getElementById('historySection').addEventListener('toggle', (e) => {
if (e.target.open) UI.fetchHistory();
});

/* ── Init ── */
UI.fetchList();
</script>
Expand Down
125 changes: 125 additions & 0 deletions src/lib/CmsService.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,131 @@ export default class CmsService {
return { ref: draftRef, sha: newSha, prev: draftSha };
}

/**
* Returns version history for an article by walking the parent chain.
* @param {{ slug: string, limit?: number }} options
* @returns {Promise<Array<{ sha: string, title: string, status: string, author: string, date: string }>>}
*/
async getArticleHistory({ slug, limit = 50 }) {
const canonicalSlug = canonicalizeSlug(slug);
const draftRef = this._refFor(canonicalSlug, 'articles');
const sha = await this.graph.readRef(draftRef);

if (!sha) {
throw new CmsValidationError(
`Article not found: "${canonicalSlug}"`,
{ code: 'article_not_found', field: 'slug' }
);
}

const versions = [];
let current = sha;

while (current && versions.length < limit) {
const info = await this.graph.getNodeInfo(current);
const message = await this.graph.showNode(current);
const decoded = this.codec.decode(message);

versions.push({
sha: current,
title: decoded.title,
status: decoded.trailers?.status || 'draft',
author: info.author,
date: info.date,
});

current = info.parents?.[0] || null;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return versions;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Reads full content of a specific commit by SHA.
* @param {{ slug: string, sha: string }} options
* @returns {Promise<{ sha: string, title: string, body: string, trailers: object }>}
*/
async readVersion({ slug, sha }) {
const canonicalSlug = canonicalizeSlug(slug);
const draftRef = this._refFor(canonicalSlug, 'articles');
const draftSha = await this.graph.readRef(draftRef);

if (!draftSha) {
throw new CmsValidationError(
`Article not found: "${canonicalSlug}"`,
{ code: 'article_not_found', field: 'slug' }
);
}

const message = await this.graph.showNode(sha);
const decoded = this.codec.decode(message);
Comment on lines +413 to +414
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict readVersion to the article's commit lineage

readVersion accepts both slug and sha, but after checking that the slug exists it directly calls showNode(sha) without verifying ancestry, so GET /api/cms/show-version can return content from a different article (or any decodable commit) whenever a valid SHA is supplied. This breaks slug scoping and can leak unrelated version content in real usage (for example when stale/incorrect SHAs are sent from the UI or API clients).

Useful? React with 👍 / 👎.

return { sha, title: decoded.title, body: decoded.body, trailers: decoded.trailers };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Restores content from a historical SHA as a new draft commit.
* @param {{ slug: string, sha: string }} options
* @returns {Promise<{ ref: string, sha: string, prev: string }>}
*/
async restoreVersion({ slug, sha }) {
const canonicalSlug = canonicalizeSlug(slug);
const draftRef = this._refFor(canonicalSlug, 'articles');

const { effectiveState, draftSha } = await this._resolveArticleState(canonicalSlug);
validateTransition(effectiveState, STATES.DRAFT);

if (!draftSha) {
throw new CmsValidationError(
`Cannot restore "${canonicalSlug}": no draft ref exists`,
{ code: 'no_draft', field: 'slug' }
);
}

// Ancestry validation: walk parent chain to verify target SHA belongs to this article
let found = false;
let walk = draftSha;
let steps = 0;
const walkLimit = 200;
while (walk && steps < walkLimit) {
if (walk === sha) { found = true; break; }
const info = await this.graph.getNodeInfo(walk);
walk = info.parents?.[0] || null;
steps++;
}
if (!found) {
throw new CmsValidationError(
`SHA "${sha}" is not in the history of article "${canonicalSlug}"`,
{ code: 'invalid_version_for_article', field: 'sha' }
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Read target SHA content
const targetMessage = await this.graph.showNode(sha);
const decoded = this.codec.decode(targetMessage);
const { updatedat: _, restoredfromsha: _r, restoredat: _ra, ...restTrailers } = decoded.trailers;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const newMessage = this.codec.encode({
title: decoded.title,
body: decoded.body,
trailers: {
...restTrailers,
status: STATES.DRAFT,
updatedAt: new Date().toISOString(),
restoredFromSha: sha,
restoredAt: new Date().toISOString(),
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

const newSha = await this.graph.commitNode({
message: newMessage,
parents: [draftSha],
sign: process.env.CMS_SIGN === '1',
});

await this._updateRef({ ref: draftRef, newSha, oldSha: draftSha });
return { ref: draftRef, sha: newSha, prev: draftSha };
}

/**
* Uploads an asset and returns its manifest and CAS info.
*/
Expand Down
Loading