diff --git a/CHANGELOG.md b/CHANGELOG.md
index b3118d2..23998d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -40,6 +47,9 @@ All notable changes to git-cms are documented in this file.
- **(P2) SRI hashes:** Add `integrity` + `crossorigin` to marked and DOMPurify CDN script tags
- **(P2) Null guards:** `revertArticle` and `unpublishArticle` throw `no_draft` when draft ref is missing; `_resolveArticleState` throws `article_not_found` when both draft and published refs are missing
- **(P2) uploadAsset DI guard:** Throw `unsupported_in_di_mode` when `cas`/`vault` are null
+- **(P1) Path traversal in upload handler:** Sanitize user-controlled `filename` to `path.basename()` preventing writes outside tmpDir
+- **(P1) readVersion lineage scoping:** `readVersion` now validates SHA ancestry (prevents cross-article content leakage)
+- **(P1) readVersion published fallback:** `readVersion` checks both draft and published refs (consistent with `getArticleHistory`)
- **(P2) Trailer key casing:** Use camelCase `updatedAt` in `unpublishArticle` and `revertArticle` (was lowercase `updatedat` which broke `renderBadges` lookups); destructure out decoded lowercase key before spreading to avoid `TrailerInvalidError`
- **(P2) XSS in `escAttr`:** Escape single quotes (`'` → `'`) to prevent injection into single-quoted attributes
- **(P2) Supply-chain hardening:** Vendor Open Props CSS files locally (`public/css/`) instead of `@import` from unpkg, eliminating CDN dependency and SRI gap
@@ -53,5 +63,14 @@ All notable changes to git-cms are documented in this file.
- DI-mode `_updateRef` now performs manual CAS check against `oldSha`
- Server tests assert setup call status codes to surface silent failures
- Vitest exclude glob `test/git-e2e*` → `test/git-e2e**` to cover future subdirectories
+- Admin UI: reset history panel state (versions list, preview, selection) when creating a new article to prevent stale data
+- Defensive `|| {}` guard on `decoded.trailers` destructuring in `unpublishArticle` and `revertArticle` (prevents TypeError if trailers is undefined)
+- `readVersion` now returns `trailers: decoded.trailers || {}` ensuring callers always receive an object
+- Upload handler: moved tmpDir cleanup to `finally` block preventing temp directory leaks on failure
+- **(P1) sendError info leak:** 500 responses now return generic 'Internal server error' instead of raw `err.message` (prevents leaking file paths, git subprocess details, or internal state)
+- **(P2) readBody O(n²):** `readBody` now accumulates chunks in an array and uses `Buffer.concat` instead of repeated string concatenation
+- Admin UI: `loadArticle` unconditionally resets `historyVersions` and `selectedVersion` to prevent stale history state when switching articles with the panel closed
+- Admin UI: `selectVersion` guards against out-of-order async responses (prevents stale preview flash from rapid clicks)
+- **(P2) walkLimit divergence:** Extracted `HISTORY_WALK_LIMIT` as a shared exported constant used by both `_validateAncestry` and the server's history limit clamp
[Unreleased]: https://github.com/flyingrobots/git-cms/compare/main...git-stunts
diff --git a/package.json b/package.json
index e3d02f5..c65371d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "git-cms",
- "version": "1.0.0",
+ "version": "1.0.2",
"description": "A serverless, database-free CMS built on Git plumbing.",
"type": "module",
"bin": {
diff --git a/public/index.html b/public/index.html
index 6cd5884..b4371da 100644
--- a/public/index.html
+++ b/public/index.html
@@ -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);
@@ -457,6 +501,17 @@
Git CMS
+
+ Version History
+
+
+
+
+
+
+
+
+
Drop files here or