Merge feat/md-history: markdown edit-history + creator-owned working folders

Server-side edit-history versioning for working-folder markdown (content-
addressed .history store + browse version-history viewer with diff/restore),
and the creator-owned working-folder model with document-controller-gated
files at the working/ root.
This commit is contained in:
ZDDC 2026-06-01 10:06:25 -05:00
commit db96333718
34 changed files with 2348 additions and 41 deletions

View file

@ -684,7 +684,9 @@ There are **no hardcoded folder names** — the canonical project structure is d
**Project shape (after the May 2026 reshape).** `archive/` is the only physical project-root directory. Everything party-scoped lives uniformly under `archive/<party>/{ssr.yaml, mdl/, rsk/, received/, issued/, incoming/, working/<email>/, staging/<batch>/, reviewing/<tracking>/}`. Six sibling top-level URLs are **virtual aggregators**, never on disk: **Project shape (after the May 2026 reshape).** `archive/` is the only physical project-root directory. Everything party-scoped lives uniformly under `archive/<party>/{ssr.yaml, mdl/, rsk/, received/, issued/, incoming/, working/<email>/, staging/<batch>/, reviewing/<tracking>/}`. Six sibling top-level URLs are **virtual aggregators**, never on disk:
- **Row rollups** (tables tool, `default_tool: tables`) — `<project>/ssr`, `<project>/mdl`, `<project>/rsk`. Synthesise one row per party (SSR) or per row file across parties (MDL/RSK), with the source party injected as a synthesised `$party` column. The `$` sigil marks the column system-managed: the tables tool renders it read-only and strips it before submitting a write. Form-mode "+ Add row" on a rollup view prompts for `party` (the routing key, stored in the form schema as a real input field; stripped on write because the folder name *is* the identity). - **Row rollups** (tables tool, `default_tool: tables`) — `<project>/ssr`, `<project>/mdl`, `<project>/rsk`. Synthesise one row per party (SSR) or per row file across parties (MDL/RSK), with the source party injected as a synthesised `$party` column. The `$` sigil marks the column system-managed: the tables tool renders it read-only and strips it before submitting a write. Form-mode "+ Add row" on a rollup view prompts for `party` (the routing key, stored in the form schema as a real input field; stripped on write because the folder name *is* the identity).
- **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `<project>/working`, `<project>/staging`, `<project>/reviewing`. List the parties whose `archive/<party>/<slot>/` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `<project>/<slot>/<party>[/<rest>]` 302-redirect to the canonical `<project>/archive/<party>/<slot>[/<rest>]`. No writes through the virtual URL space; sharing/bookmarks land on the canonical path after the redirect. - **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `<project>/working`, `<project>/staging`, `<project>/reviewing`. List the parties whose `archive/<party>/<slot>/` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `<project>/<slot>/<party>[/<rest>]` 302-redirect to the canonical `<project>/archive/<party>/<slot>[/<rest>]`. (A party name fails `ValidPartyName` only if it contains a character outside `[A-Za-z0-9.-]` — the resolver then declines to redirect and the path is treated physically; see `working/` below.) Sharing/bookmarks land on the canonical path after the redirect.
`working/` is the one folder-nav aggregator that **also materialises on disk**: it doubles as a shared project-level drafting space holding **creator-owned working folders** at `<project>/working/<folder>/`. The slot dir is instantiated lazily by `EnsureCanonicalAncestors` the first time real content is created beneath it (it stays a plain dir — never auto-owned), and each `<folder>/` a user creates gets an *unfenced* auto-own `.zddc` (`history: true` inherits in, so markdown drafts there are versioned). Authorisation splits dir-vs-file at the root: project members may create folders (`project_team: rc` in the defaults), but a **bare file directly at the `working/` root is reserved for the `document_controller`** — regular users work inside a folder; the DC creates files at the root or promotes one up with a MOVE. Enforced in `serveFilePut`/`serveFileMove` via `isProjectWorkingRootFile` + `zddc.IsRoleMemberAt`, independent of the ACL verb (since mkdir and file-PUT both authorise as `ActionCreate`). The earlier per-user `working/<email>/` "personal workspace" idea was dropped as more complexity than it earned. `staging`/`reviewing` remain non-materialising — `EnsureCanonicalAncestors` still rejects physical writes under them.
Mkdir at the project root is restricted: only `archive` and `_`/`.`-prefixed system names are accepted (`handler/fileapi.go: rejectProjectRootMkdir`). Any other name — including the six virtual aggregator names, which would shadow the virtual surface — returns 409 Conflict. This is the only structural mkdir guard; deeper paths are governed by `auto_own:` + `worm:` + ACL. Mkdir at the project root is restricted: only `archive` and `_`/`.`-prefixed system names are accepted (`handler/fileapi.go: rejectProjectRootMkdir`). Any other name — including the six virtual aggregator names, which would shadow the virtual surface — returns 409 Conflict. This is the only structural mkdir guard; deeper paths are governed by `auto_own:` + `worm:` + ACL.

View file

@ -32,6 +32,7 @@ concat_files \
"css/base.css" \ "css/base.css" \
"css/tree.css" \ "css/tree.css" \
"css/preview-yaml.css" \ "css/preview-yaml.css" \
"css/history.css" \
> "$css_temp" > "$css_temp"
# JS files: shared canonical helpers, then browse modules. # JS files: shared canonical helpers, then browse modules.
@ -47,6 +48,7 @@ concat_files \
"../shared/vendor/toastui-editor-all.min.js" \ "../shared/vendor/toastui-editor-all.min.js" \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-filter.js" \ "../shared/zddc-filter.js" \
"../shared/diff.js" \
"../shared/zip-source.js" \ "../shared/zip-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \ "../shared/toast.js" \
@ -71,6 +73,7 @@ concat_files \
"js/plan-review.js" \ "js/plan-review.js" \
"js/accept-transmittal.js" \ "js/accept-transmittal.js" \
"js/stage.js" \ "js/stage.js" \
"js/history.js" \
"js/create-transmittal.js" \ "js/create-transmittal.js" \
"js/events.js" \ "js/events.js" \
"js/app.js" \ "js/app.js" \

131
browse/css/history.css Normal file
View file

@ -0,0 +1,131 @@
/* history.css — markdown edit-history modal (browse/js/history.js). */
.md-history-box {
background: var(--bg, #fff);
color: var(--fg, #111);
padding: 1.1rem 1.35rem;
border-radius: 6px;
min-width: 30rem;
max-width: 56rem;
width: 80vw;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
font-family: inherit;
}
.md-history-title {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
overflow-wrap: anywhere;
}
.md-history-body {
display: flex;
flex-direction: column;
min-height: 0; /* allow inner scroll regions to shrink */
overflow: hidden;
}
.md-history-hint {
margin: 0 0 0.6rem 0;
font-size: 0.82rem;
color: var(--muted, #666);
}
.md-history-empty {
margin: 1rem 0;
font-size: 0.9rem;
color: var(--muted, #666);
}
/* ── version list ── */
.md-history-list {
overflow-y: auto;
border: 1px solid var(--border, #ddd);
border-radius: 4px;
}
.md-history-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border, #eee);
font-size: 0.88rem;
}
.md-history-row:last-child { border-bottom: none; }
.md-history-row.is-current { background: var(--accent-bg, rgba(60, 130, 246, 0.08)); }
.md-history-meta {
display: flex;
align-items: baseline;
gap: 0.75rem;
min-width: 0;
}
.md-history-time { font-variant-numeric: tabular-nums; }
.md-history-by { color: var(--muted, #555); overflow-wrap: anywhere; }
.md-history-size { color: var(--muted, #888); font-size: 0.8rem; }
.md-history-badge {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.05rem 0.4rem;
border-radius: 10px;
background: var(--accent, #3c82f6);
color: #fff;
}
.md-history-actions { display: flex; gap: 0.35rem; }
/* ── single-version view ── */
.md-history-pre {
flex: 1 1 auto;
overflow: auto;
margin: 0;
padding: 0.6rem 0.8rem;
border: 1px solid var(--border, #ddd);
border-radius: 4px;
background: var(--code-bg, #f7f7f8);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* ── diff view ── */
.md-diff {
flex: 1 1 auto;
overflow: auto;
border: 1px solid var(--border, #ddd);
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
line-height: 1.45;
}
.md-diff-line { display: flex; gap: 0.5rem; padding: 0 0.5rem; white-space: pre-wrap; overflow-wrap: anywhere; }
.md-diff-gutter { flex: 0 0 1ch; text-align: center; color: var(--muted, #999); user-select: none; }
.md-diff-text { flex: 1 1 auto; }
.md-diff-add { background: rgba(46, 160, 67, 0.16); }
.md-diff-add .md-diff-gutter { color: #2ea043; }
.md-diff-del { background: rgba(248, 81, 73, 0.16); }
.md-diff-del .md-diff-gutter { color: #f85149; }
.md-diff-eq { color: var(--muted, #777); }
.md-diff-old { color: #f85149; }
.md-diff-new { color: #2ea043; }
/* ── footer ── */
.md-history-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.85rem;
}

View file

@ -1061,6 +1061,24 @@
if (s) s.invokeUnstage(c.node); if (s) s.invokeUnstage(c.node);
} }
}, },
// ── Version history (history:true subtree, real files only) ──
// Server-mode only: the audit trail (who saved when) is
// server-stamped, so there's no offline equivalent. node.history
// is set by the listing when this file sits in a history-enabled
// cascade subtree (working/).
{
label: 'History…',
icon: '🕘',
visible: function (c) {
if (!serverMode) return false;
if (c.node.isDir || c.node.isZip || c.node.virtual) return false;
return !!c.node.history;
},
action: function (c) {
var h = window.app.modules.history;
if (h) h.open(c.node);
}
},
{ separator: true }, { separator: true },
// ── View ── // ── View ──

396
browse/js/history.js Normal file
View file

@ -0,0 +1,396 @@
// history.js — markdown edit-history viewer for the browse tool.
//
// Surfaced by events.js as a "History…" right-click item on files in a
// history:true cascade subtree (working/). Server mode only — the audit
// trail (who saved when) is stamped server-side, so there's no offline
// equivalent.
//
// Talks to the zddc-server history endpoints on the file's own URL:
// GET <url>?history=1 → JSON [{ts, by, sha, prev, bytes, current}]
// GET <url>?history=<sha> → that version's raw bytes
// Restore re-PUTs a chosen version's bytes to <url>, which the server
// records as a new version (forward-only; never destructive).
//
// Diffs are computed client-side via window.zddc.diff (shared/diff.js).
(function () {
'use strict';
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function toast(msg, kind) {
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(msg, kind || 'info');
}
}
// Append ?history=<v> (or &history=) to a file URL.
function histURL(baseURL, v) {
var sep = baseURL.indexOf('?') === -1 ? '?' : '&';
return baseURL + sep + 'history=' + encodeURIComponent(v);
}
function fmtTime(ts) {
var d = new Date(ts);
if (isNaN(d.getTime())) return ts || '';
return d.toLocaleString();
}
function fmtBytes(n) {
if (n == null) return '';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / (1024 * 1024)).toFixed(1) + ' MB';
}
// Can the principal write (restore) to this file? Mirrors the
// events.js Rename/Delete gating: verbs===undefined means a non-zddc
// backend (no cascade signal) → allow the attempt; otherwise check w.
function canRestore(node) {
if (!node || !node.url) return false;
if (!window.zddc || !window.zddc.cap) return true;
if (typeof node.verbs !== 'string') return true;
return window.zddc.cap.has(node, 'w');
}
async function fetchList(node) {
var resp = await fetch(histURL(node.url, '1'), {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var data = await resp.json();
return Array.isArray(data) ? data : [];
}
async function fetchVersion(node, sha) {
var resp = await fetch(histURL(node.url, sha), { credentials: 'same-origin' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return await resp.text();
}
// ── Modal shell ──────────────────────────────────────────────────────
// One overlay; its body is swapped between the list, a diff, and a
// single-version view. Returns { overlay, body, setTitle, close }.
function makeModal(titleText) {
var overlay = document.createElement('div');
overlay.className = 'modal-overlay md-history-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div');
box.className = 'md-history-box';
var title = document.createElement('h2');
title.className = 'md-history-title';
title.textContent = titleText;
var body = document.createElement('div');
body.className = 'md-history-body';
box.appendChild(title);
box.appendChild(body);
overlay.appendChild(box);
document.body.appendChild(overlay);
function close() {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
document.removeEventListener('keydown', onKey);
}
function onKey(e) { if (e.key === 'Escape') close(); }
document.addEventListener('keydown', onKey);
overlay.addEventListener('mousedown', function (e) {
if (e.target === overlay) close();
});
return {
overlay: overlay,
body: body,
setTitle: function (t) { title.textContent = t; },
close: close
};
}
function footerBar() {
var f = document.createElement('div');
f.className = 'md-history-footer';
return f;
}
function button(label, opts) {
opts = opts || {};
var b = document.createElement('button');
b.type = 'button';
b.textContent = label;
if (opts.primary) b.className = 'btn-primary';
if (opts.disabled) b.disabled = true;
if (opts.onClick) b.addEventListener('click', opts.onClick);
return b;
}
// ── List view ──────────────────────────────────────────────────────
function renderList(modal, node, entries) {
modal.setTitle('History — ' + node.name);
var body = modal.body;
body.innerHTML = '';
if (!entries.length) {
var empty = document.createElement('p');
empty.className = 'md-history-empty';
empty.textContent = 'No saved versions yet. Each save of this file is recorded here.';
body.appendChild(empty);
var f0 = footerBar();
f0.appendChild(button('Close', { onClick: modal.close }));
body.appendChild(f0);
return;
}
var hint = document.createElement('p');
hint.className = 'md-history-hint';
hint.textContent = 'Newest first. Select two versions to diff.';
body.appendChild(hint);
var list = document.createElement('div');
list.className = 'md-history-list';
var selected = []; // shas, in click order (max 2)
var diffBtn;
function syncDiffBtn() {
if (diffBtn) diffBtn.disabled = selected.length !== 2;
}
entries.forEach(function (ent) {
var row = document.createElement('div');
row.className = 'md-history-row' + (ent.current ? ' is-current' : '');
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'md-history-pick';
cb.addEventListener('change', function () {
if (cb.checked) {
selected.push(ent.sha);
// Keep at most two: drop the oldest selection.
if (selected.length > 2) {
var dropped = selected.shift();
var others = list.querySelectorAll('.md-history-pick');
others.forEach(function (o, i) {
if (o !== cb && entries[i] && entries[i].sha === dropped) o.checked = false;
});
}
} else {
selected = selected.filter(function (s) { return s !== ent.sha; });
}
syncDiffBtn();
});
var meta = document.createElement('div');
meta.className = 'md-history-meta';
meta.innerHTML =
'<span class="md-history-time">' + escapeHtml(fmtTime(ent.ts)) + '</span>' +
'<span class="md-history-by">' + escapeHtml(ent.by || '—') + '</span>' +
'<span class="md-history-size">' + escapeHtml(fmtBytes(ent.bytes)) + '</span>' +
(ent.current ? '<span class="md-history-badge">current</span>' : '');
var actions = document.createElement('div');
actions.className = 'md-history-actions';
actions.appendChild(button('View', {
onClick: function () { renderView(modal, node, ent, entries); }
}));
if (!ent.current && canRestore(node)) {
actions.appendChild(button('Restore', {
onClick: function () { restore(modal, node, ent); }
}));
}
row.appendChild(cb);
row.appendChild(meta);
row.appendChild(actions);
list.appendChild(row);
});
body.appendChild(list);
var f = footerBar();
diffBtn = button('Diff selected', {
primary: true, disabled: true,
onClick: function () {
if (selected.length !== 2) return;
// Order oldest→newest by the entries' position (newest
// first in the list), so the diff reads old → new.
var picks = entries.filter(function (e) { return selected.indexOf(e.sha) !== -1; });
picks.sort(function (a, b) { return (a.ts < b.ts ? -1 : 1); });
renderDiff(modal, node, picks[0], picks[1], entries);
}
});
f.appendChild(diffBtn);
f.appendChild(button('Close', { onClick: modal.close }));
body.appendChild(f);
}
// ── Single-version view ──────────────────────────────────────────────
async function renderView(modal, node, ent, entries) {
modal.setTitle('Version — ' + fmtTime(ent.ts));
var body = modal.body;
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
var text;
try {
text = await fetchVersion(node, ent.sha);
} catch (e) {
body.innerHTML = '';
var err = document.createElement('p');
err.className = 'md-history-empty';
err.textContent = 'Could not load this version: ' + (e.message || e);
body.appendChild(err);
return;
}
body.innerHTML = '';
var meta = document.createElement('p');
meta.className = 'md-history-hint';
meta.textContent = (ent.by || '—') + ' · ' + fmtTime(ent.ts);
body.appendChild(meta);
var pre = document.createElement('pre');
pre.className = 'md-history-pre';
pre.textContent = text;
body.appendChild(pre);
var f = footerBar();
f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } }));
if (!ent.current && canRestore(node)) {
f.appendChild(button('Restore this version', {
primary: true, onClick: function () { restore(modal, node, ent); }
}));
}
body.appendChild(f);
}
// ── Diff view ─────────────────────────────────────────────────────────
async function renderDiff(modal, node, oldEnt, newEnt, entries) {
modal.setTitle('Diff');
var body = modal.body;
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
var oldText, newText;
try {
oldText = await fetchVersion(node, oldEnt.sha);
newText = await fetchVersion(node, newEnt.sha);
} catch (e) {
body.innerHTML = '';
var err = document.createElement('p');
err.className = 'md-history-empty';
err.textContent = 'Could not load versions: ' + (e.message || e);
body.appendChild(err);
return;
}
body.innerHTML = '';
var hdr = document.createElement('p');
hdr.className = 'md-history-hint';
hdr.innerHTML =
'<span class="md-diff-old">' + escapeHtml(fmtTime(oldEnt.ts)) + ' · ' + escapeHtml(oldEnt.by || '—') + '</span>' +
' → ' +
'<span class="md-diff-new">' + escapeHtml(fmtTime(newEnt.ts)) + ' · ' + escapeHtml(newEnt.by || '—') + '</span>';
body.appendChild(hdr);
var ops = (window.zddc && window.zddc.diff)
? window.zddc.diff.lines(oldText, newText)
: null;
var pane = document.createElement('div');
pane.className = 'md-diff';
if (!ops) {
pane.textContent = 'Diff unavailable (diff module not loaded).';
} else {
var unchanged = true;
ops.forEach(function (op) {
if (op.type !== 'eq') unchanged = false;
var line = document.createElement('div');
line.className = 'md-diff-line md-diff-' + op.type;
var gutter = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' ');
var g = document.createElement('span');
g.className = 'md-diff-gutter';
g.textContent = gutter;
var t = document.createElement('span');
t.className = 'md-diff-text';
t.textContent = op.text;
line.appendChild(g);
line.appendChild(t);
pane.appendChild(line);
});
if (unchanged) {
var same = document.createElement('div');
same.className = 'md-diff-line md-diff-eq';
same.textContent = '(no differences)';
pane.appendChild(same);
}
}
body.appendChild(pane);
if (window.zddc && window.zddc.diff && ops) {
var s = window.zddc.diff.stats(ops);
var statline = document.createElement('p');
statline.className = 'md-history-hint';
statline.textContent = '+' + s.added + ' / ' + s.removed;
body.appendChild(statline);
}
var f = footerBar();
f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } }));
body.appendChild(f);
}
// ── Restore ───────────────────────────────────────────────────────────
async function restore(modal, node, ent) {
if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) {
return;
}
try {
var text = await fetchVersion(node, ent.sha);
var resp = await fetch(node.url, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'text/markdown' },
body: text
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
toast('Restored version from ' + fmtTime(ent.ts), 'success');
// Reflect the new head: refetch the list.
var entries = await fetchList(node);
renderList(modal, node, entries);
// If the file is open in the preview pane, reload it.
var preview = window.app && window.app.modules && window.app.modules.preview;
if (preview && typeof preview.showFilePreview === 'function') {
try { preview.showFilePreview(node); } catch (_e) { /* best effort */ }
}
} catch (e) {
toast('Restore failed: ' + (e.message || e), 'error');
}
}
// ── Entry point ─────────────────────────────────────────────────────
async function open(node) {
if (!node || !node.url) {
toast('History is only available in server mode.', 'error');
return;
}
var modal = makeModal('History — ' + node.name);
modal.body.innerHTML = '<p class="md-history-hint">Loading…</p>';
try {
var entries = await fetchList(node);
renderList(modal, node, entries);
} catch (e) {
modal.body.innerHTML = '';
var err = document.createElement('p');
err.className = 'md-history-empty';
err.textContent = 'Could not load history: ' + (e.message || e);
modal.body.appendChild(err);
var f = footerBar();
f.appendChild(button('Close', { onClick: modal.close }));
modal.body.appendChild(f);
}
}
window.app.modules.history = { open: open };
})();

View file

@ -60,6 +60,12 @@
// whatever the server enforces on the // whatever the server enforces on the
// actual PUT/DELETE still apply. // actual PUT/DELETE still apply.
verbs: typeof e.verbs === 'string' ? e.verbs : undefined, verbs: typeof e.verbs === 'string' ? e.verbs : undefined,
// Server-computed: true when this file lives in a history:true
// cascade subtree, so every save is versioned and
// GET <url>?history lists prior versions. Drives the "History…"
// context-menu affordance (server mode only — offline has no
// authenticated identity to attribute saves to).
history: !!e.history,
// FS-API specific (null in server mode): // FS-API specific (null in server mode):
handle: null handle: null
}; };

View file

@ -75,6 +75,10 @@ export default defineConfig({
name: 'zddc', name: 'zddc',
testMatch: 'zddc.spec.js', testMatch: 'zddc.spec.js',
}, },
{
name: 'diff',
testMatch: 'diff.spec.js',
},
{ {
name: 'form-safety', name: 'form-safety',
testMatch: 'form-safety.spec.js', testMatch: 'form-safety.spec.js',

108
shared/diff.js Normal file
View file

@ -0,0 +1,108 @@
/*
* shared/diff.js a small, dependency-free text diff.
*
* Attaches to window.zddc.diff. Must load AFTER shared/zddc.js (which
* creates the window.zddc object). Used by the browse tool's markdown
* version-history viewer to show what changed between any two saved
* versions; kept in shared/ so other tools can reuse it.
*
* API:
* window.zddc.diff.lines(oldStr, newStr)
* [{ type: 'eq'|'del'|'add', text }] line-level diff (LCS)
* window.zddc.diff.words(oldStr, newStr)
* [{ type: 'eq'|'del'|'add', text }] token-level diff for one
* changed line (whitespace-preserving), for intra-line highlights
* window.zddc.diff.stats(ops) { added, removed }
*
* The line diff trims the common prefix/suffix before running the O(n*m)
* LCS dynamic program, so a small edit in a large file stays cheap. A
* safety cap falls back to "replace whole block" when the changed middle
* is pathologically large, so the UI never freezes.
*/
(function () {
'use strict';
var LCS_CELL_CAP = 4000000; // ~4M cells (n*m) before the fallback
function splitLines(s) {
return String(s == null ? '' : s).replace(/\r\n/g, '\n').split('\n');
}
// LCS diff of two arrays of strings → ordered [{type, text}] ops.
function lcsDiff(a, b) {
var n = a.length, m = b.length;
if (n === 0 && m === 0) return [];
if (n === 0) return b.map(function (t) { return { type: 'add', text: t }; });
if (m === 0) return a.map(function (t) { return { type: 'del', text: t }; });
if (n * m > LCS_CELL_CAP) {
// Too large to diff finely without risking a UI stall: treat
// the whole block as a wholesale replacement.
var out = a.map(function (t) { return { type: 'del', text: t }; });
return out.concat(b.map(function (t) { return { type: 'add', text: t }; }));
}
// dp[i][j] = LCS length of a[i:] and b[j:].
var dp = new Array(n + 1);
for (var i = 0; i <= n; i++) dp[i] = new Array(m + 1).fill(0);
for (var ii = n - 1; ii >= 0; ii--) {
for (var jj = m - 1; jj >= 0; jj--) {
if (a[ii] === b[jj]) dp[ii][jj] = dp[ii + 1][jj + 1] + 1;
else dp[ii][jj] = Math.max(dp[ii + 1][jj], dp[ii][jj + 1]);
}
}
var ops = [], i = 0, j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) { ops.push({ type: 'eq', text: a[i] }); i++; j++; }
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: 'del', text: a[i] }); i++; }
else { ops.push({ type: 'add', text: b[j] }); j++; }
}
while (i < n) ops.push({ type: 'del', text: a[i++] });
while (j < m) ops.push({ type: 'add', text: b[j++] });
return ops;
}
function diffLines(oldStr, newStr) {
var a = splitLines(oldStr), b = splitLines(newStr);
var ops = [];
// Common prefix.
var start = 0;
while (start < a.length && start < b.length && a[start] === b[start]) start++;
// Common suffix (not overlapping the prefix).
var endA = a.length, endB = b.length;
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
for (var p = 0; p < start; p++) ops.push({ type: 'eq', text: a[p] });
var mid = lcsDiff(a.slice(start, endA), b.slice(start, endB));
for (var k = 0; k < mid.length; k++) ops.push(mid[k]);
for (var s = endA; s < a.length; s++) ops.push({ type: 'eq', text: a[s] });
return ops;
}
// Whitespace-preserving tokenization: words and the runs of
// whitespace between them are separate tokens, so a re-diff lines up
// on word boundaries while keeping the original spacing renderable.
function tokenize(s) {
return String(s == null ? '' : s).split(/(\s+)/).filter(function (x) { return x !== ''; });
}
function diffWords(oldStr, newStr) {
return lcsDiff(tokenize(oldStr), tokenize(newStr));
}
function stats(ops) {
var added = 0, removed = 0;
for (var i = 0; i < ops.length; i++) {
if (ops[i].type === 'add') added++;
else if (ops[i].type === 'del') removed++;
}
return { added: added, removed: removed };
}
if (!window.zddc) {
throw new Error('shared/diff.js: window.zddc must be loaded first');
}
window.zddc.diff = { lines: diffLines, words: diffWords, stats: stats };
})();

View file

@ -2,8 +2,9 @@
<html lang="en"> <html lang="en">
<head><meta charset="utf-8"><title>ZDDC library test shim</title></head> <head><meta charset="utf-8"><title>ZDDC library test shim</title></head>
<body> <body>
<!-- Loads shared/zddc.js + shared/hash.js so Playwright tests can call window.zddc.* --> <!-- Loads shared/zddc.js + shared/hash.js + shared/diff.js so Playwright tests can call window.zddc.* -->
<script src="zddc.js"></script> <script src="zddc.js"></script>
<script src="hash.js"></script> <script src="hash.js"></script>
<script src="diff.js"></script>
</body> </body>
</html> </html>

71
tests/diff.spec.js Normal file
View file

@ -0,0 +1,71 @@
/**
* Tests for shared/diff.js the dependency-free text diff used by the
* browse tool's markdown version-history viewer.
*
* Runs against the same shim as zddc.spec.js (shared/zddc-test.html,
* which loads shared/diff.js and exposes window.zddc.diff).
*/
import { test, expect } from '@playwright/test';
import * as path from 'path';
const SHIM_PATH = 'file://' + path.resolve('shared/zddc-test.html');
async function diff(page, fn, ...args) {
return page.evaluate(
([fn, args]) => window.zddc.diff[fn](...args),
[fn, args]
);
}
test.beforeEach(async ({ page }) => {
await page.goto(SHIM_PATH, { waitUntil: 'load' });
});
test('diff module is attached to window.zddc', async ({ page }) => {
const present = await page.evaluate(() =>
!!(window.zddc && window.zddc.diff &&
typeof window.zddc.diff.lines === 'function' &&
typeof window.zddc.diff.words === 'function'));
expect(present).toBe(true);
});
test('identical text produces only eq ops', async ({ page }) => {
const ops = await diff(page, 'lines', 'a\nb\nc', 'a\nb\nc');
expect(ops.every(o => o.type === 'eq')).toBe(true);
});
test('a changed middle line shows del then add', async ({ page }) => {
const ops = await diff(page, 'lines', 'a\nb\nc', 'a\nB\nc');
const compact = ops.map(o => `${o.type}:${o.text}`).join('|');
expect(compact).toContain('eq:a');
expect(compact).toContain('del:b');
expect(compact).toContain('add:B');
expect(compact).toContain('eq:c');
});
test('stats count added and removed lines', async ({ page }) => {
const addStats = await diff(page, 'stats', await diff(page, 'lines', 'a\nb', 'a\nx\nb'));
expect(addStats).toEqual({ added: 1, removed: 0 });
const delStats = await diff(page, 'stats', await diff(page, 'lines', 'a\nb\nc', 'a\nc'));
expect(delStats).toEqual({ added: 0, removed: 1 });
});
test('pure insertion at end', async ({ page }) => {
const ops = await diff(page, 'lines', 'one\ntwo', 'one\ntwo\nthree');
const added = ops.filter(o => o.type === 'add').map(o => o.text);
expect(added).toEqual(['three']);
expect(ops.filter(o => o.type === 'del')).toHaveLength(0);
});
test('word diff aligns on word boundaries, preserving spaces', async ({ page }) => {
const ops = await diff(page, 'words', 'the quick fox', 'the slow fox');
const changed = ops.filter(o => o.type !== 'eq').map(o => `${o.type}:${o.text}`);
expect(changed).toEqual(['del:quick', 'add:slow']);
});
test('empty inputs do not throw', async ({ page }) => {
const ops = await diff(page, 'lines', '', '');
expect(Array.isArray(ops)).toBe(true);
});

View file

@ -1323,11 +1323,23 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// see RecognizeVirtualConvert). The .md source serves // see RecognizeVirtualConvert). The .md source serves
// normally here.) // normally here.)
// Record-history list: GET <record>.yaml?history=1 returns the // Edit-history: ACL already passed (parent-dir chain).
// list of prior revisions stored under <dir>/.history/<base>/. // - Records (.yaml rows): GET <record>.yaml?history=1 lists prior
// ACL already passed (parent-dir chain). Non-record paths fall // revisions stored under <dir>/.history/<base>/ (audit in-body).
// through to the normal file serve. // - Text (markdown) under a history: true subtree:
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Get("history") == "1" { // ?history=1 lists versions; ?history=<sha> returns that version's
// bytes. Audit lives in <dir>/.history/<stem>/log.jsonl.
// Non-history paths fall through to the normal file serve.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Has("history") {
version := r.URL.Query().Get("history")
if handler.IsTextHistoryCandidate(absPath) {
if chain.EffectiveHistory() {
handler.ServeTextHistory(w, r, absPath, version)
} else {
http.NotFound(w, r)
}
return
}
handler.ServeHistoryList(w, r, absPath) handler.ServeHistoryList(w, r, absPath)
return return
} }

View file

@ -298,9 +298,11 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
return req.WithContext(handler.WithEmail(req.Context(), email)) return req.WithContext(handler.WithEmail(req.Context(), email))
} }
// PUT a new file via dispatch. // PUT a new file via dispatch. Files live in a sub-folder under
// working/ (creator-owned); bare files at the working/ root are
// document-controller-only (see TestFileAPI_WorkingRootFileDocControllerOnly).
body := []byte("note body") body := []byte("note body")
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/note.md", strings.NewReader(string(body))), "alice@example.com") req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/drafts/note.md", strings.NewReader(string(body))), "alice@example.com")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req) dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
@ -308,7 +310,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
} }
// GET it back. // GET it back.
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/note.md", nil), "alice@example.com") req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/drafts/note.md", nil), "alice@example.com")
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req) dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != string(body) { if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
@ -316,9 +318,9 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
} }
// MOVE it. // MOVE it.
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/note.md", nil), "alice@example.com") req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/drafts/note.md", nil), "alice@example.com")
req.Header.Set("X-ZDDC-Op", "move") req.Header.Set("X-ZDDC-Op", "move")
req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/renamed.md") req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/drafts/renamed.md")
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req) dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@ -326,7 +328,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
} }
// DELETE it. // DELETE it.
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/renamed.md", nil), "alice@example.com") req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/drafts/renamed.md", nil), "alice@example.com")
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req) dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusNoContent { if rec.Code != http.StatusNoContent {
@ -1063,4 +1065,3 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
} }
}) })
} }

View file

@ -2582,7 +2582,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp">v0.0.24</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-29 19:37:02 · c489a78</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

View file

@ -2329,6 +2329,138 @@ body {
max-width: 32rem; max-width: 32rem;
} }
/* history.css — markdown edit-history modal (browse/js/history.js). */
.md-history-box {
background: var(--bg, #fff);
color: var(--fg, #111);
padding: 1.1rem 1.35rem;
border-radius: 6px;
min-width: 30rem;
max-width: 56rem;
width: 80vw;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
font-family: inherit;
}
.md-history-title {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
overflow-wrap: anywhere;
}
.md-history-body {
display: flex;
flex-direction: column;
min-height: 0; /* allow inner scroll regions to shrink */
overflow: hidden;
}
.md-history-hint {
margin: 0 0 0.6rem 0;
font-size: 0.82rem;
color: var(--muted, #666);
}
.md-history-empty {
margin: 1rem 0;
font-size: 0.9rem;
color: var(--muted, #666);
}
/* ── version list ── */
.md-history-list {
overflow-y: auto;
border: 1px solid var(--border, #ddd);
border-radius: 4px;
}
.md-history-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border, #eee);
font-size: 0.88rem;
}
.md-history-row:last-child { border-bottom: none; }
.md-history-row.is-current { background: var(--accent-bg, rgba(60, 130, 246, 0.08)); }
.md-history-meta {
display: flex;
align-items: baseline;
gap: 0.75rem;
min-width: 0;
}
.md-history-time { font-variant-numeric: tabular-nums; }
.md-history-by { color: var(--muted, #555); overflow-wrap: anywhere; }
.md-history-size { color: var(--muted, #888); font-size: 0.8rem; }
.md-history-badge {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.05rem 0.4rem;
border-radius: 10px;
background: var(--accent, #3c82f6);
color: #fff;
}
.md-history-actions { display: flex; gap: 0.35rem; }
/* ── single-version view ── */
.md-history-pre {
flex: 1 1 auto;
overflow: auto;
margin: 0;
padding: 0.6rem 0.8rem;
border: 1px solid var(--border, #ddd);
border-radius: 4px;
background: var(--code-bg, #f7f7f8);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* ── diff view ── */
.md-diff {
flex: 1 1 auto;
overflow: auto;
border: 1px solid var(--border, #ddd);
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
line-height: 1.45;
}
.md-diff-line { display: flex; gap: 0.5rem; padding: 0 0.5rem; white-space: pre-wrap; overflow-wrap: anywhere; }
.md-diff-gutter { flex: 0 0 1ch; text-align: center; color: var(--muted, #999); user-select: none; }
.md-diff-text { flex: 1 1 auto; }
.md-diff-add { background: rgba(46, 160, 67, 0.16); }
.md-diff-add .md-diff-gutter { color: #2ea043; }
.md-diff-del { background: rgba(248, 81, 73, 0.16); }
.md-diff-del .md-diff-gutter { color: #f85149; }
.md-diff-eq { color: var(--muted, #777); }
.md-diff-old { color: #f85149; }
.md-diff-new { color: #2ea043; }
/* ── footer ── */
.md-history-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.85rem;
}
</style> </style>
</head> </head>
<body> <body>
@ -2344,7 +2476,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp">v0.0.24</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-29 19:37:02 · c489a78</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
@ -4227,6 +4359,115 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
window.zddc.filter = { parse: parse, matches: matches }; window.zddc.filter = { parse: parse, matches: matches };
})(); })();
/*
* shared/diff.js — a small, dependency-free text diff.
*
* Attaches to window.zddc.diff. Must load AFTER shared/zddc.js (which
* creates the window.zddc object). Used by the browse tool's markdown
* version-history viewer to show what changed between any two saved
* versions; kept in shared/ so other tools can reuse it.
*
* API:
* window.zddc.diff.lines(oldStr, newStr)
* → [{ type: 'eq'|'del'|'add', text }] line-level diff (LCS)
* window.zddc.diff.words(oldStr, newStr)
* → [{ type: 'eq'|'del'|'add', text }] token-level diff for one
* changed line (whitespace-preserving), for intra-line highlights
* window.zddc.diff.stats(ops) → { added, removed }
*
* The line diff trims the common prefix/suffix before running the O(n*m)
* LCS dynamic program, so a small edit in a large file stays cheap. A
* safety cap falls back to "replace whole block" when the changed middle
* is pathologically large, so the UI never freezes.
*/
(function () {
'use strict';
var LCS_CELL_CAP = 4000000; // ~4M cells (n*m) before the fallback
function splitLines(s) {
return String(s == null ? '' : s).replace(/\r\n/g, '\n').split('\n');
}
// LCS diff of two arrays of strings → ordered [{type, text}] ops.
function lcsDiff(a, b) {
var n = a.length, m = b.length;
if (n === 0 && m === 0) return [];
if (n === 0) return b.map(function (t) { return { type: 'add', text: t }; });
if (m === 0) return a.map(function (t) { return { type: 'del', text: t }; });
if (n * m > LCS_CELL_CAP) {
// Too large to diff finely without risking a UI stall: treat
// the whole block as a wholesale replacement.
var out = a.map(function (t) { return { type: 'del', text: t }; });
return out.concat(b.map(function (t) { return { type: 'add', text: t }; }));
}
// dp[i][j] = LCS length of a[i:] and b[j:].
var dp = new Array(n + 1);
for (var i = 0; i <= n; i++) dp[i] = new Array(m + 1).fill(0);
for (var ii = n - 1; ii >= 0; ii--) {
for (var jj = m - 1; jj >= 0; jj--) {
if (a[ii] === b[jj]) dp[ii][jj] = dp[ii + 1][jj + 1] + 1;
else dp[ii][jj] = Math.max(dp[ii + 1][jj], dp[ii][jj + 1]);
}
}
var ops = [], i = 0, j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) { ops.push({ type: 'eq', text: a[i] }); i++; j++; }
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: 'del', text: a[i] }); i++; }
else { ops.push({ type: 'add', text: b[j] }); j++; }
}
while (i < n) ops.push({ type: 'del', text: a[i++] });
while (j < m) ops.push({ type: 'add', text: b[j++] });
return ops;
}
function diffLines(oldStr, newStr) {
var a = splitLines(oldStr), b = splitLines(newStr);
var ops = [];
// Common prefix.
var start = 0;
while (start < a.length && start < b.length && a[start] === b[start]) start++;
// Common suffix (not overlapping the prefix).
var endA = a.length, endB = b.length;
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
for (var p = 0; p < start; p++) ops.push({ type: 'eq', text: a[p] });
var mid = lcsDiff(a.slice(start, endA), b.slice(start, endB));
for (var k = 0; k < mid.length; k++) ops.push(mid[k]);
for (var s = endA; s < a.length; s++) ops.push({ type: 'eq', text: a[s] });
return ops;
}
// Whitespace-preserving tokenization: words and the runs of
// whitespace between them are separate tokens, so a re-diff lines up
// on word boundaries while keeping the original spacing renderable.
function tokenize(s) {
return String(s == null ? '' : s).split(/(\s+)/).filter(function (x) { return x !== ''; });
}
function diffWords(oldStr, newStr) {
return lcsDiff(tokenize(oldStr), tokenize(newStr));
}
function stats(ops) {
var added = 0, removed = 0;
for (var i = 0; i < ops.length; i++) {
if (ops[i].type === 'add') added++;
else if (ops[i].type === 'del') removed++;
}
return { added: added, removed: removed };
}
if (!window.zddc) {
throw new Error('shared/diff.js: window.zddc must be loaded first');
}
window.zddc.diff = { lines: diffLines, words: diffWords, stats: stats };
})();
// shared/zip-source.js — present the contents of a .zip as a tree of // shared/zip-source.js — present the contents of a .zip as a tree of
// File System Access API handles, so tools written against // File System Access API handles, so tools written against
// FileSystemDirectoryHandle / FileSystemFileHandle (archive's scanner, // FileSystemDirectoryHandle / FileSystemFileHandle (archive's scanner,
@ -6790,6 +7031,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// whatever the server enforces on the // whatever the server enforces on the
// actual PUT/DELETE still apply. // actual PUT/DELETE still apply.
verbs: typeof e.verbs === 'string' ? e.verbs : undefined, verbs: typeof e.verbs === 'string' ? e.verbs : undefined,
// Server-computed: true when this file lives in a history:true
// cascade subtree, so every save is versioned and
// GET <url>?history lists prior versions. Drives the "History…"
// context-menu affordance (server mode only — offline has no
// authenticated identity to attribute saves to).
history: !!e.history,
// FS-API specific (null in server mode): // FS-API specific (null in server mode):
handle: null handle: null
}; };
@ -11375,6 +11622,403 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
}; };
})(); })();
// history.js — markdown edit-history viewer for the browse tool.
//
// Surfaced by events.js as a "History…" right-click item on files in a
// history:true cascade subtree (working/). Server mode only — the audit
// trail (who saved when) is stamped server-side, so there's no offline
// equivalent.
//
// Talks to the zddc-server history endpoints on the file's own URL:
// GET <url>?history=1 → JSON [{ts, by, sha, prev, bytes, current}]
// GET <url>?history=<sha> → that version's raw bytes
// Restore re-PUTs a chosen version's bytes to <url>, which the server
// records as a new version (forward-only; never destructive).
//
// Diffs are computed client-side via window.zddc.diff (shared/diff.js).
(function () {
'use strict';
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function toast(msg, kind) {
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(msg, kind || 'info');
}
}
// Append ?history=<v> (or &history=) to a file URL.
function histURL(baseURL, v) {
var sep = baseURL.indexOf('?') === -1 ? '?' : '&';
return baseURL + sep + 'history=' + encodeURIComponent(v);
}
function fmtTime(ts) {
var d = new Date(ts);
if (isNaN(d.getTime())) return ts || '';
return d.toLocaleString();
}
function fmtBytes(n) {
if (n == null) return '';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / (1024 * 1024)).toFixed(1) + ' MB';
}
// Can the principal write (restore) to this file? Mirrors the
// events.js Rename/Delete gating: verbs===undefined means a non-zddc
// backend (no cascade signal) → allow the attempt; otherwise check w.
function canRestore(node) {
if (!node || !node.url) return false;
if (!window.zddc || !window.zddc.cap) return true;
if (typeof node.verbs !== 'string') return true;
return window.zddc.cap.has(node, 'w');
}
async function fetchList(node) {
var resp = await fetch(histURL(node.url, '1'), {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var data = await resp.json();
return Array.isArray(data) ? data : [];
}
async function fetchVersion(node, sha) {
var resp = await fetch(histURL(node.url, sha), { credentials: 'same-origin' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return await resp.text();
}
// ── Modal shell ──────────────────────────────────────────────────────
// One overlay; its body is swapped between the list, a diff, and a
// single-version view. Returns { overlay, body, setTitle, close }.
function makeModal(titleText) {
var overlay = document.createElement('div');
overlay.className = 'modal-overlay md-history-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div');
box.className = 'md-history-box';
var title = document.createElement('h2');
title.className = 'md-history-title';
title.textContent = titleText;
var body = document.createElement('div');
body.className = 'md-history-body';
box.appendChild(title);
box.appendChild(body);
overlay.appendChild(box);
document.body.appendChild(overlay);
function close() {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
document.removeEventListener('keydown', onKey);
}
function onKey(e) { if (e.key === 'Escape') close(); }
document.addEventListener('keydown', onKey);
overlay.addEventListener('mousedown', function (e) {
if (e.target === overlay) close();
});
return {
overlay: overlay,
body: body,
setTitle: function (t) { title.textContent = t; },
close: close
};
}
function footerBar() {
var f = document.createElement('div');
f.className = 'md-history-footer';
return f;
}
function button(label, opts) {
opts = opts || {};
var b = document.createElement('button');
b.type = 'button';
b.textContent = label;
if (opts.primary) b.className = 'btn-primary';
if (opts.disabled) b.disabled = true;
if (opts.onClick) b.addEventListener('click', opts.onClick);
return b;
}
// ── List view ──────────────────────────────────────────────────────
function renderList(modal, node, entries) {
modal.setTitle('History — ' + node.name);
var body = modal.body;
body.innerHTML = '';
if (!entries.length) {
var empty = document.createElement('p');
empty.className = 'md-history-empty';
empty.textContent = 'No saved versions yet. Each save of this file is recorded here.';
body.appendChild(empty);
var f0 = footerBar();
f0.appendChild(button('Close', { onClick: modal.close }));
body.appendChild(f0);
return;
}
var hint = document.createElement('p');
hint.className = 'md-history-hint';
hint.textContent = 'Newest first. Select two versions to diff.';
body.appendChild(hint);
var list = document.createElement('div');
list.className = 'md-history-list';
var selected = []; // shas, in click order (max 2)
var diffBtn;
function syncDiffBtn() {
if (diffBtn) diffBtn.disabled = selected.length !== 2;
}
entries.forEach(function (ent) {
var row = document.createElement('div');
row.className = 'md-history-row' + (ent.current ? ' is-current' : '');
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'md-history-pick';
cb.addEventListener('change', function () {
if (cb.checked) {
selected.push(ent.sha);
// Keep at most two: drop the oldest selection.
if (selected.length > 2) {
var dropped = selected.shift();
var others = list.querySelectorAll('.md-history-pick');
others.forEach(function (o, i) {
if (o !== cb && entries[i] && entries[i].sha === dropped) o.checked = false;
});
}
} else {
selected = selected.filter(function (s) { return s !== ent.sha; });
}
syncDiffBtn();
});
var meta = document.createElement('div');
meta.className = 'md-history-meta';
meta.innerHTML =
'<span class="md-history-time">' + escapeHtml(fmtTime(ent.ts)) + '</span>' +
'<span class="md-history-by">' + escapeHtml(ent.by || '—') + '</span>' +
'<span class="md-history-size">' + escapeHtml(fmtBytes(ent.bytes)) + '</span>' +
(ent.current ? '<span class="md-history-badge">current</span>' : '');
var actions = document.createElement('div');
actions.className = 'md-history-actions';
actions.appendChild(button('View', {
onClick: function () { renderView(modal, node, ent, entries); }
}));
if (!ent.current && canRestore(node)) {
actions.appendChild(button('Restore', {
onClick: function () { restore(modal, node, ent); }
}));
}
row.appendChild(cb);
row.appendChild(meta);
row.appendChild(actions);
list.appendChild(row);
});
body.appendChild(list);
var f = footerBar();
diffBtn = button('Diff selected', {
primary: true, disabled: true,
onClick: function () {
if (selected.length !== 2) return;
// Order oldest→newest by the entries' position (newest
// first in the list), so the diff reads old → new.
var picks = entries.filter(function (e) { return selected.indexOf(e.sha) !== -1; });
picks.sort(function (a, b) { return (a.ts < b.ts ? -1 : 1); });
renderDiff(modal, node, picks[0], picks[1], entries);
}
});
f.appendChild(diffBtn);
f.appendChild(button('Close', { onClick: modal.close }));
body.appendChild(f);
}
// ── Single-version view ──────────────────────────────────────────────
async function renderView(modal, node, ent, entries) {
modal.setTitle('Version — ' + fmtTime(ent.ts));
var body = modal.body;
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
var text;
try {
text = await fetchVersion(node, ent.sha);
} catch (e) {
body.innerHTML = '';
var err = document.createElement('p');
err.className = 'md-history-empty';
err.textContent = 'Could not load this version: ' + (e.message || e);
body.appendChild(err);
return;
}
body.innerHTML = '';
var meta = document.createElement('p');
meta.className = 'md-history-hint';
meta.textContent = (ent.by || '—') + ' · ' + fmtTime(ent.ts);
body.appendChild(meta);
var pre = document.createElement('pre');
pre.className = 'md-history-pre';
pre.textContent = text;
body.appendChild(pre);
var f = footerBar();
f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } }));
if (!ent.current && canRestore(node)) {
f.appendChild(button('Restore this version', {
primary: true, onClick: function () { restore(modal, node, ent); }
}));
}
body.appendChild(f);
}
// ── Diff view ─────────────────────────────────────────────────────────
async function renderDiff(modal, node, oldEnt, newEnt, entries) {
modal.setTitle('Diff');
var body = modal.body;
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
var oldText, newText;
try {
oldText = await fetchVersion(node, oldEnt.sha);
newText = await fetchVersion(node, newEnt.sha);
} catch (e) {
body.innerHTML = '';
var err = document.createElement('p');
err.className = 'md-history-empty';
err.textContent = 'Could not load versions: ' + (e.message || e);
body.appendChild(err);
return;
}
body.innerHTML = '';
var hdr = document.createElement('p');
hdr.className = 'md-history-hint';
hdr.innerHTML =
'<span class="md-diff-old">' + escapeHtml(fmtTime(oldEnt.ts)) + ' · ' + escapeHtml(oldEnt.by || '—') + '</span>' +
' → ' +
'<span class="md-diff-new">' + escapeHtml(fmtTime(newEnt.ts)) + ' · ' + escapeHtml(newEnt.by || '—') + '</span>';
body.appendChild(hdr);
var ops = (window.zddc && window.zddc.diff)
? window.zddc.diff.lines(oldText, newText)
: null;
var pane = document.createElement('div');
pane.className = 'md-diff';
if (!ops) {
pane.textContent = 'Diff unavailable (diff module not loaded).';
} else {
var unchanged = true;
ops.forEach(function (op) {
if (op.type !== 'eq') unchanged = false;
var line = document.createElement('div');
line.className = 'md-diff-line md-diff-' + op.type;
var gutter = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' ');
var g = document.createElement('span');
g.className = 'md-diff-gutter';
g.textContent = gutter;
var t = document.createElement('span');
t.className = 'md-diff-text';
t.textContent = op.text;
line.appendChild(g);
line.appendChild(t);
pane.appendChild(line);
});
if (unchanged) {
var same = document.createElement('div');
same.className = 'md-diff-line md-diff-eq';
same.textContent = '(no differences)';
pane.appendChild(same);
}
}
body.appendChild(pane);
if (window.zddc && window.zddc.diff && ops) {
var s = window.zddc.diff.stats(ops);
var statline = document.createElement('p');
statline.className = 'md-history-hint';
statline.textContent = '+' + s.added + ' / ' + s.removed;
body.appendChild(statline);
}
var f = footerBar();
f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } }));
body.appendChild(f);
}
// ── Restore ───────────────────────────────────────────────────────────
async function restore(modal, node, ent) {
if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) {
return;
}
try {
var text = await fetchVersion(node, ent.sha);
var resp = await fetch(node.url, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'text/markdown' },
body: text
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
toast('Restored version from ' + fmtTime(ent.ts), 'success');
// Reflect the new head: refetch the list.
var entries = await fetchList(node);
renderList(modal, node, entries);
// If the file is open in the preview pane, reload it.
var preview = window.app && window.app.modules && window.app.modules.preview;
if (preview && typeof preview.showFilePreview === 'function') {
try { preview.showFilePreview(node); } catch (_e) { /* best effort */ }
}
} catch (e) {
toast('Restore failed: ' + (e.message || e), 'error');
}
}
// ── Entry point ─────────────────────────────────────────────────────
async function open(node) {
if (!node || !node.url) {
toast('History is only available in server mode.', 'error');
return;
}
var modal = makeModal('History — ' + node.name);
modal.body.innerHTML = '<p class="md-history-hint">Loading…</p>';
try {
var entries = await fetchList(node);
renderList(modal, node, entries);
} catch (e) {
modal.body.innerHTML = '';
var err = document.createElement('p');
err.className = 'md-history-empty';
err.textContent = 'Could not load history: ' + (e.message || e);
modal.body.appendChild(err);
var f = footerBar();
f.appendChild(button('Close', { onClick: modal.close }));
modal.body.appendChild(f);
}
}
window.app.modules.history = { open: open };
})();
// create-transmittal.js — folder-creation plumbing for outgoing // create-transmittal.js — folder-creation plumbing for outgoing
// transmittals. // transmittals.
// //
@ -12585,6 +13229,24 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
if (s) s.invokeUnstage(c.node); if (s) s.invokeUnstage(c.node);
} }
}, },
// ── Version history (history:true subtree, real files only) ──
// Server-mode only: the audit trail (who saved when) is
// server-stamped, so there's no offline equivalent. node.history
// is set by the listing when this file sits in a history-enabled
// cascade subtree (working/).
{
label: 'History…',
icon: '🕘',
visible: function (c) {
if (!serverMode) return false;
if (c.node.isDir || c.node.isZip || c.node.virtual) return false;
return !!c.node.history;
},
action: function (c) {
var h = window.app.modules.history;
if (h) h.open(c.node);
}
},
{ separator: true }, { separator: true },
// ── View ── // ── View ──

View file

@ -1793,7 +1793,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp">v0.0.24</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-29 19:37:02 · c489a78</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>

View file

@ -1536,7 +1536,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp">v0.0.24</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-29 19:37:02 · c489a78</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -2635,7 +2635,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp">v0.0.24</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-29 19:37:02 · c489a78</span></span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.24 archive=v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
transmittal=v0.0.24 transmittal=v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
classifier=v0.0.24 classifier=v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
landing=v0.0.24 landing=v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
form=v0.0.24 form=v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
tables=v0.0.24 tables=v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
browse=v0.0.24 browse=v0.0.25-beta · 2026-05-29 19:37:02 · c489a78

View file

@ -102,6 +102,10 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
principal := zddc.Principal{Email: userEmail, Elevated: elevated} principal := zddc.Principal{Email: userEmail, Elevated: elevated}
parentActiveAdmin := elevated && userEmail != "" && parentActiveAdmin := elevated && userEmail != "" &&
zddc.IsAdminForChain(parentChain, userEmail) zddc.IsAdminForChain(parentChain, userEmail)
// Edit-history is a subtree behavior; resolved once for this dir and
// flagged on each eligible (markdown) file so the browse client knows
// where to offer the History/diff affordances.
historyEnabled := parentChain.EffectiveHistory()
for _, entry := range entries { for _, entry := range entries {
name := entry.Name() name := entry.Name()
@ -189,6 +193,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
writableBit = zddc.VerbA writableBit = zddc.VerbA
} }
fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin
fi.History = historyEnabled && strings.EqualFold(filepath.Ext(name), ".md")
result = append(result, fi) result = append(result, fi)
} }

View file

@ -392,6 +392,17 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if !authorizeAction(cfg, w, r, abs, cleanURL, action) { if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
return return
} }
// Files placed DIRECTLY at the project-level virtual working/ root are
// reserved for the document controller. Regular users create folders
// under working/ and work inside them; the DC creates files at the
// root or promotes them up from a folder (see serveFileMove). Files in
// sub-folders and the working/.zddc config are unaffected.
if filepath.Base(abs) != ".zddc" && isProjectWorkingRootFile(cfg.Root, abs) &&
!zddc.IsRoleMemberAt(cfg.Root, filepath.Dir(abs), "document_controller", EmailFromContext(r)) {
auditFile(r, "put", cleanURL, http.StatusForbidden, 0, nil)
http.Error(w, "Forbidden — files cannot be created directly in working/; create a folder and work inside it. Only the document controller may place files at the working/ root.", http.StatusForbidden)
return
}
if !checkIfMatch(w, r, abs) { if !checkIfMatch(w, r, abs) {
return return
} }
@ -432,6 +443,16 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
} }
finalBody = res.FinalBody finalBody = res.FinalBody
stamped = true stamped = true
} else if IsTextHistoryCandidate(abs) && zddc.HistoryAt(cfg.Root, filepath.Dir(abs)) {
// History-enabled text (markdown) files: snapshot every save
// into <dir>/.history/<stem>/ with a server-stamped audit line,
// then write the live file. The live file at its natural path
// remains the source of truth.
if err := WriteTextWithHistory(abs, body, EmailFromContext(r)); err != nil {
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
} else { } else {
if err := zddc.WriteAtomic(abs, body); err != nil { if err := zddc.WriteAtomic(abs, body); err != nil {
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err) auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
@ -645,6 +666,13 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) { if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) {
return return
} }
// Promoting a file to the project-level working/ root is reserved for
// the document controller, same as a direct create there (serveFilePut).
if filepath.Base(dstAbs) != ".zddc" && isProjectWorkingRootFile(cfg.Root, dstAbs) &&
!zddc.IsRoleMemberAt(cfg.Root, filepath.Dir(dstAbs), "document_controller", EmailFromContext(r)) {
http.Error(w, "Forbidden — only the document controller may move files to the working/ root.", http.StatusForbidden)
return
}
if !checkIfMatch(w, r, srcAbs) { if !checkIfMatch(w, r, srcAbs) {
return return
} }
@ -800,6 +828,26 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// Returns (true, reason) when the request should be 409'd. Returns // Returns (true, reason) when the request should be 409'd. Returns
// (false, "") when the target is at any other depth or carries an // (false, "") when the target is at any other depth or carries an
// allowed name. // allowed name.
// isProjectWorkingRootFile reports whether abs targets a file sitting
// directly in the project-level virtual working/ aggregator —
// <project>/working/<file> — as opposed to a file inside a sub-folder
// (<project>/working/<folder>/<file>, depth 4+) or anywhere else.
// Used to gate file creation/promotion at the working/ root to the
// document controller; everything deeper is ordinary creator-owned
// working space.
func isProjectWorkingRootFile(fsRoot, abs string) bool {
rel, err := filepath.Rel(fsRoot, abs)
if err != nil {
return false
}
rel = filepath.ToSlash(rel)
if strings.HasPrefix(rel, "../") || rel == "." {
return false
}
parts := strings.Split(rel, "/")
return len(parts) == 3 && strings.EqualFold(parts[1], "working")
}
func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) { func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) {
rel, err := filepath.Rel(fsRoot, abs) rel, err := filepath.Rel(fsRoot, abs)
if err != nil { if err != nil {

View file

@ -720,3 +720,104 @@ func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
// archive/<party>/staging/<batch>/ and working at archive/<party>/ // archive/<party>/staging/<batch>/ and working at archive/<party>/
// working/<email>/, the project-level pairing no longer maps cleanly. // working/<email>/, the project-level pairing no longer maps cleanly.
// Tests for the removed behaviour have been deleted.) // Tests for the removed behaviour have been deleted.)
// workingRootSetup builds a root that grants the team rwcd and names a
// document controller (the standard role ships empty; a deployment
// populates it). Returns the same do() helper shape as fileAPITestSetup.
func workingRootSetup(t *testing.T) (root string, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder) {
t.Helper()
root = t.TempDir()
rootZddc := "acl:\n permissions:\n \"*@example.com\": rwcd\n" +
"roles:\n document_controller:\n members: [dc@example.com]\n"
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(rootZddc), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", MaxWriteBytes: 1 << 20}
do = func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
} else {
req = httptest.NewRequest(method, target, nil)
}
for k, v := range headers {
req.Header.Set(k, v)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
return rec
}
return root, do
}
// Files directly at the project-level working/ root are document-
// controller-only; folders (and files inside them) are open to the team
// and become creator-owned.
func TestFileAPI_WorkingRootFileDocControllerOnly(t *testing.T) {
root, do := workingRootSetup(t)
// Non-DC user: a bare file at the working/ root is forbidden.
if rec := do(http.MethodPut, "/Proj/working/memo.md", "alice@example.com", []byte("x"), nil); rec.Code != http.StatusForbidden {
t.Errorf("non-DC file at working/ root: want 403, got %d: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "memo.md")); !os.IsNotExist(err) {
t.Errorf("forbidden file must not be written")
}
// Same user CAN create a folder and work inside it; the folder
// becomes creator-owned (unfenced auto-own .zddc).
if rec := do(http.MethodPut, "/Proj/working/drafts/notes.md", "alice@example.com", []byte("x"), nil); rec.Code != http.StatusCreated {
t.Fatalf("user file inside working folder: want 201, got %d: %s", rec.Code, rec.Body.String())
}
z, err := os.ReadFile(filepath.Join(root, "Proj", "working", "drafts", ".zddc"))
if err != nil {
t.Fatalf("creator-owned folder auto-own .zddc missing: %v", err)
}
if !strings.Contains(string(z), "alice@example.com: rwcda") {
t.Errorf("creator folder should grant alice rwcda; got: %s", z)
}
// The document controller CAN place a file at the working/ root.
if rec := do(http.MethodPut, "/Proj/working/memo.md", "dc@example.com", []byte("x"), nil); rec.Code != http.StatusCreated {
t.Errorf("DC file at working/ root: want 201, got %d: %s", rec.Code, rec.Body.String())
}
}
// Promoting a file from a working folder up to the working/ root is
// document-controller-only, mirroring direct creation.
func TestFileAPI_WorkingRootMoveDocControllerOnly(t *testing.T) {
root, do := workingRootSetup(t)
// alice owns a folder with a file.
if rec := do(http.MethodPut, "/Proj/working/drafts/a.md", "alice@example.com", []byte("body"), nil); rec.Code != http.StatusCreated {
t.Fatalf("seed file: want 201, got %d: %s", rec.Code, rec.Body.String())
}
// alice cannot promote it to the working/ root.
rec := do(http.MethodPost, "/Proj/working/drafts/a.md", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "move",
"X-ZDDC-Destination": "/Proj/working/a.md",
})
if rec.Code != http.StatusForbidden {
t.Errorf("non-DC move to working/ root: want 403, got %d: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "a.md")); !os.IsNotExist(err) {
t.Errorf("forbidden move must not create the destination")
}
// The document controller can.
rec = do(http.MethodPost, "/Proj/working/drafts/a.md", "dc@example.com", nil, map[string]string{
"X-ZDDC-Op": "move",
"X-ZDDC-Destination": "/Proj/working/a.md",
})
if rec.Code != http.StatusOK {
t.Errorf("DC move to working/ root: want 200, got %d: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "a.md")); err != nil {
t.Errorf("DC move should land the file at the root: %v", err)
}
}

View file

@ -802,3 +802,243 @@ func atoiSafe(s string) int {
} }
return n return n
} }
// ─── Markdown / text edit-history ────────────────────────────────────────
//
// History-enabled text files (a `history: true` .zddc subtree — see
// zddc.PolicyChain.EffectiveHistory) keep every saved version under
// <dir>/.history/<stem>/. Unlike records, text files can't carry audit
// fields in-body, so authorship + ordering live in a sidecar log:
//
// .history/<stem>/<contentSha>.md one immutable blob per distinct content
// .history/<stem>/log.jsonl one MdHistoryEntry per save, in order
//
// Blobs are content-addressed (so reverting to an earlier exact state
// reuses its blob); the live file's content always equals the last log
// entry's sha. Email + timestamp are stamped server-side from the
// authenticated principal — never client-supplied, mirroring the record
// path's anti-forgery stance.
const mdHistoryLogName = "log.jsonl"
// MdHistoryEntry is one saved version of a history-tracked text file.
type MdHistoryEntry struct {
Ts string `json:"ts"` // RFC3339Nano UTC of the save
By string `json:"by"` // authenticated principal email ("" if pre-history)
Sha string `json:"sha"` // content hash = version id = blob stem
Prev string `json:"prev,omitempty"` // prior version's sha
Bytes int `json:"bytes"` // size of this version
Current bool `json:"current,omitempty"` // derived by ListMdHistory; never persisted
}
// mdVersionIDRe validates a client-supplied version id so it can't
// escape the history dir. Version ids are lowercase hex content hashes.
var mdVersionIDRe = regexp.MustCompile(`^[0-9a-f]{1,64}$`)
// IsTextHistoryCandidate reports whether abs is a text file eligible for
// edit-history versioning. Scoped to markdown for now (the browse editor
// surface); widen here to add .txt etc.
func IsTextHistoryCandidate(abs string) bool {
return strings.EqualFold(filepath.Ext(abs), ".md")
}
func mdHistoryDir(abs string) string {
return filepath.Join(filepath.Dir(abs), historyDirName, stripExt(filepath.Base(abs)))
}
// WriteTextWithHistory snapshots prior and new content into
// .history/<stem>/, appends an audit line, then writes the live file
// atomically. No-op saves (content identical to the current head) don't
// create a version. History is written BEFORE the live file so a crash
// can't lose a version the live write would have superseded.
func WriteTextWithHistory(abs string, body []byte, principalEmail string) error {
histDir := mdHistoryDir(abs)
logPath := filepath.Join(histDir, mdHistoryLogName)
ext := filepath.Ext(abs)
// Prior live content (nil on create).
var prior []byte
priorSha := ""
priorMtime := ""
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
if data, rerr := os.ReadFile(abs); rerr == nil {
prior = data
priorSha = fileETag(prior)
priorMtime = info.ModTime().UTC().Format(time.RFC3339Nano)
}
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
newSha := fileETag(body)
if principalEmail == "" {
principalEmail = "anonymous"
}
now := time.Now().UTC().Format(time.RFC3339Nano)
if err := os.MkdirAll(histDir, 0o755); err != nil {
return fmt.Errorf("mkdir history: %w", err)
}
writeBlob := func(sha string, data []byte) error {
blob := filepath.Join(histDir, sha+ext)
if _, err := os.Stat(blob); errors.Is(err, os.ErrNotExist) {
return zddc.WriteAtomic(blob, data)
}
return nil
}
logExisted := false
if _, err := os.Stat(logPath); err == nil {
logExisted = true
}
var entries []MdHistoryEntry
if logExisted {
existing, err := readMdLog(logPath)
if err != nil {
return err
}
entries = existing
}
// Lazy-seed: a file that pre-existed history enablement has prior
// bytes but no log. Capture that state as the origin version so the
// chain isn't missing its start. Authorship is unknown ("").
if prior != nil && !logExisted {
if err := writeBlob(priorSha, prior); err != nil {
return err
}
ts := priorMtime
if ts == "" {
ts = now
}
entries = append(entries, MdHistoryEntry{Ts: ts, By: "", Sha: priorSha, Bytes: len(prior)})
}
lastSha := ""
if len(entries) > 0 {
lastSha = entries[len(entries)-1].Sha
}
// Record this save unless it's a no-op (identical to the head).
changed := newSha != lastSha
if changed {
if err := writeBlob(newSha, body); err != nil {
return err
}
entries = append(entries, MdHistoryEntry{Ts: now, By: principalEmail, Sha: newSha, Prev: priorSha, Bytes: len(body)})
}
// Rewrite the log atomically (CIFS-safe; avoids partial-append
// corruption) only when something actually changed.
if changed || (prior != nil && !logExisted) {
if err := writeMdLog(logPath, entries); err != nil {
return err
}
}
return zddc.WriteAtomic(abs, body)
}
func readMdLog(path string) ([]MdHistoryEntry, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var out []MdHistoryEntry
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var e MdHistoryEntry
if err := json.Unmarshal([]byte(line), &e); err != nil {
continue // skip a malformed line rather than fail the whole read
}
out = append(out, e)
}
return out, nil
}
func writeMdLog(path string, entries []MdHistoryEntry) error {
var sb strings.Builder
for _, e := range entries {
e.Current = false // never persist the derived flag
b, err := json.Marshal(e)
if err != nil {
return err
}
sb.Write(b)
sb.WriteByte('\n')
}
return zddc.WriteAtomic(path, []byte(sb.String()))
}
// ListMdHistory returns the saved versions of abs, newest first, with
// Current set on the version whose content matches the live file.
func ListMdHistory(abs string) ([]MdHistoryEntry, error) {
logPath := filepath.Join(mdHistoryDir(abs), mdHistoryLogName)
entries, err := readMdLog(logPath)
if err != nil {
return nil, err
}
liveSha := ""
if data, err := os.ReadFile(abs); err == nil {
liveSha = fileETag(data)
}
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Ts > entries[j].Ts })
// Mark the newest entry whose content matches the live file as the
// current version. A revert reuses an earlier blob, so the same sha
// can appear twice — only the most recent save is "current".
for i := range entries {
if entries[i].Sha == liveSha {
entries[i].Current = true
break
}
}
return entries, nil
}
// ServeTextHistory dispatches GET <file>?history=... for history-enabled
// text files: `?history=1` (or empty / `list`) returns the version list
// as JSON; `?history=<sha>` returns that version's raw bytes. ACL on the
// live file has already been checked by the caller.
func ServeTextHistory(w http.ResponseWriter, r *http.Request, abs, version string) {
if !IsTextHistoryCandidate(abs) {
http.NotFound(w, r)
return
}
if version == "" || version == "1" || version == "list" {
entries, err := ListMdHistory(abs)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "history-list")
_ = json.NewEncoder(w).Encode(entries)
return
}
if !mdVersionIDRe.MatchString(version) {
http.Error(w, "Bad Request — invalid version id", http.StatusBadRequest)
return
}
blob := filepath.Join(mdHistoryDir(abs), version+filepath.Ext(abs))
data, err := os.ReadFile(blob)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("X-ZDDC-Source", "history-version")
w.Header().Set("X-ZDDC-History-Version", version)
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
_, _ = w.Write(data)
}

View file

@ -0,0 +1,221 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
func mustNoErr(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
func countBlobs(t *testing.T, histDir string) int {
t.Helper()
ents, err := os.ReadDir(histDir)
if err != nil {
t.Fatalf("read history dir: %v", err)
}
n := 0
for _, e := range ents {
if strings.HasSuffix(e.Name(), ".md") {
n++
}
}
return n
}
func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "notes.md")
histDir := filepath.Join(dir, ".history", "notes")
sha1 := fileETag([]byte("v1"))
sha2 := fileETag([]byte("v2"))
// ── create ──
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "alice@x.com"))
if b, _ := os.ReadFile(abs); string(b) != "v1" {
t.Fatalf("live = %q, want v1", b)
}
entries, err := ListMdHistory(abs)
mustNoErr(t, err)
if len(entries) != 1 {
t.Fatalf("after create: want 1 entry, got %d", len(entries))
}
if entries[0].By != "alice@x.com" {
t.Errorf("by = %q, want alice@x.com", entries[0].By)
}
if entries[0].Sha != sha1 {
t.Errorf("sha = %q, want %q", entries[0].Sha, sha1)
}
if !entries[0].Current {
t.Errorf("v1 should be current")
}
if _, err := os.Stat(filepath.Join(histDir, sha1+".md")); err != nil {
t.Errorf("v1 blob missing: %v", err)
}
// ── update ──
time.Sleep(2 * time.Millisecond) // distinct RFC3339Nano ts for ordering
mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com"))
if b, _ := os.ReadFile(abs); string(b) != "v2" {
t.Fatalf("live = %q, want v2", b)
}
entries, _ = ListMdHistory(abs)
if len(entries) != 2 {
t.Fatalf("after update: want 2 entries, got %d", len(entries))
}
// newest first
if entries[0].Sha != sha2 || !entries[0].Current {
t.Errorf("head = %+v, want v2 current", entries[0])
}
if entries[0].Prev != sha1 {
t.Errorf("v2.prev = %q, want %q", entries[0].Prev, sha1)
}
if entries[1].Sha != sha1 || entries[1].Current {
t.Errorf("tail = %+v, want v1 non-current", entries[1])
}
if entries[1].By != "alice@x.com" {
t.Errorf("v1 author lost: %q", entries[1].By)
}
// ── no-op save (identical content) → dedup, no new entry ──
mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com"))
entries, _ = ListMdHistory(abs)
if len(entries) != 2 {
t.Fatalf("dedup failed: want 2 entries, got %d", len(entries))
}
// ── restore v1 content → new log entry, blob reused ──
time.Sleep(2 * time.Millisecond)
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "carol@x.com"))
if b, _ := os.ReadFile(abs); string(b) != "v1" {
t.Fatalf("live = %q, want restored v1", b)
}
entries, _ = ListMdHistory(abs)
if len(entries) != 3 {
t.Fatalf("after restore: want 3 entries, got %d", len(entries))
}
if entries[0].Sha != sha1 || !entries[0].Current || entries[0].By != "carol@x.com" {
t.Errorf("head = %+v, want restored v1 by carol, current", entries[0])
}
// Only the newest matching entry is current, even though the oldest
// entry has the same sha.
if entries[2].Current {
t.Errorf("oldest v1 entry should not be current: %+v", entries[2])
}
// Content-addressed: only two distinct blobs (v1, v2) despite 3 saves.
if n := countBlobs(t, histDir); n != 2 {
t.Errorf("distinct blobs = %d, want 2", n)
}
}
func TestWriteTextWithHistory_LazySeedPreexisting(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "doc.md")
// Simulate a file that existed before history was enabled.
mustNoErr(t, zddc.WriteAtomic(abs, []byte("legacy")))
mustNoErr(t, WriteTextWithHistory(abs, []byte("edited"), "dave@x.com"))
entries, err := ListMdHistory(abs)
mustNoErr(t, err)
if len(entries) != 2 {
t.Fatalf("lazy-seed: want 2 entries (seeded prior + new), got %d", len(entries))
}
// newest = the edit; oldest = the seeded legacy version (author unknown)
if entries[0].By != "dave@x.com" || entries[0].Sha != fileETag([]byte("edited")) {
t.Errorf("head = %+v, want edit by dave", entries[0])
}
if entries[1].By != "" || entries[1].Sha != fileETag([]byte("legacy")) {
t.Errorf("seed = %+v, want legacy with empty author", entries[1])
}
}
func TestWriteTextWithHistory_EmptyAuthorAnonymous(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "x.md")
mustNoErr(t, WriteTextWithHistory(abs, []byte("a"), ""))
entries, _ := ListMdHistory(abs)
if len(entries) != 1 || entries[0].By != "anonymous" {
t.Fatalf("empty author should record anonymous, got %+v", entries)
}
}
func TestServeTextHistory_ListAndVersion(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "page.md")
mustNoErr(t, WriteTextWithHistory(abs, []byte("one"), "a@x.com"))
time.Sleep(2 * time.Millisecond)
mustNoErr(t, WriteTextWithHistory(abs, []byte("two"), "b@x.com"))
sha1 := fileETag([]byte("one"))
// ── list ──
req := httptest.NewRequest(http.MethodGet, "/page.md?history=1", nil)
rec := httptest.NewRecorder()
ServeTextHistory(rec, req, abs, "1")
if rec.Code != http.StatusOK {
t.Fatalf("list status = %d", rec.Code)
}
var got []MdHistoryEntry
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("list body not JSON: %v", err)
}
if len(got) != 2 || got[0].By != "b@x.com" {
t.Fatalf("list = %+v, want 2 newest-first", got)
}
// ── specific version content ──
req = httptest.NewRequest(http.MethodGet, "/page.md?history="+sha1, nil)
rec = httptest.NewRecorder()
ServeTextHistory(rec, req, abs, sha1)
if rec.Code != http.StatusOK {
t.Fatalf("version status = %d", rec.Code)
}
if rec.Body.String() != "one" {
t.Errorf("version body = %q, want %q", rec.Body.String(), "one")
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
t.Errorf("content-type = %q", ct)
}
}
func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "p.md")
mustNoErr(t, WriteTextWithHistory(abs, []byte("x"), "a@x.com"))
// Drop a secret in the parent so a successful traversal would be visible.
mustNoErr(t, zddc.WriteAtomic(filepath.Join(dir, "secret"), []byte("TOPSECRET")))
for _, bad := range []string{"../secret", "..%2Fsecret", "abc/def", "ZZZ", "deadbeef.md"} {
req := httptest.NewRequest(http.MethodGet, "/p.md?history="+url.QueryEscape(bad), nil)
rec := httptest.NewRecorder()
ServeTextHistory(rec, req, abs, bad)
if rec.Code == http.StatusOK {
t.Errorf("version %q unexpectedly served: body=%q", bad, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "TOPSECRET") {
t.Fatalf("traversal leaked secret for input %q", bad)
}
}
// Non-markdown path → 404 (history not applicable).
yamlAbs := filepath.Join(dir, "rec.yaml")
req := httptest.NewRequest(http.MethodGet, "/rec.yaml?history=1", nil)
rec := httptest.NewRecorder()
ServeTextHistory(rec, req, yamlAbs, "1")
if rec.Code != http.StatusNotFound {
t.Errorf("non-md status = %d, want 404", rec.Code)
}
}

View file

@ -1534,7 +1534,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp">v0.0.24</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-29 19:37:02 · c489a78</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -86,4 +86,12 @@ type FileInfo struct {
// as "no permissions known" and fall back to a server round-trip // as "no permissions known" and fall back to a server round-trip
// (or just disable affordances) rather than assuming any grant. // (or just disable affordances) rather than assuming any grant.
Verbs string `json:"verbs,omitempty"` Verbs string `json:"verbs,omitempty"`
// History is true when this entry is a text file in a history: true
// cascade subtree — i.e. every save is versioned into the sibling
// .history/ store and GET <url>?history=1 lists prior versions. The
// browse client uses it to show "History…" / diff affordances only
// where they apply. False/absent for directories, virtual entries,
// and files outside a history-enabled subtree.
History bool `json:"history,omitempty"`
} }

View file

@ -47,6 +47,25 @@ func (chain PolicyChain) VisibleStart(toIdx int) int {
return 0 return 0
} }
// EffectiveHistory reports whether edit-history versioning is enabled
// for writes at this chain's directory. Unlike DropTarget (leaf-only),
// history is a subtree behavior: the closest-to-leaf explicit setting
// wins and applies to all descendants. It deliberately IGNORES
// inherit:false ACL fences — versioning is a write behavior, not a
// permission, so a fenced per-user home under a history-enabled
// working/ still records history. Falls back to the embedded defaults.
func (chain PolicyChain) EffectiveHistory() bool {
for i := len(chain.Levels) - 1; i >= 0; i-- {
if v := chain.Levels[i].History; v != nil {
return *v
}
}
if v := chain.Embedded.History; v != nil {
return *v
}
return false
}
// policyCache caches effective policies keyed by dirPath. // policyCache caches effective policies keyed by dirPath.
// Values are PolicyChain. // Values are PolicyChain.
var policyCache sync.Map var policyCache sync.Map
@ -370,6 +389,7 @@ func nonZeroZddcFields(zf ZddcFile) []string {
add("auto_own_fenced", zf.AutoOwnFenced != nil) add("auto_own_fenced", zf.AutoOwnFenced != nil)
add("virtual", zf.Virtual != nil) add("virtual", zf.Virtual != nil)
add("drop_target", zf.DropTarget != nil) add("drop_target", zf.DropTarget != nil)
add("history", zf.History != nil)
add("worm", zf.Worm != nil) add("worm", zf.Worm != nil)
add("available_tools", len(zf.AvailableTools) > 0) add("available_tools", len(zf.AvailableTools) > 0)
add("received_path", zf.ReceivedPath != "") add("received_path", zf.ReceivedPath != "")

View file

@ -206,7 +206,41 @@ paths:
working: working:
default_tool: browse default_tool: browse
available_tools: [browse] available_tools: [browse]
# Project-level working is BOTH an outstanding-only aggregator of
# the per-party archive/<party>/working/ slots AND the home for
# shared, creator-owned working folders at <project>/working/
# <folder>/. (The earlier per-user <email>/ "personal workspace"
# concept was dropped — too much complexity for too little gain.
# A working folder is just a named drafting space its creator
# owns; nothing keys off the email.)
virtual: true virtual: true
# Edit-history versioning for markdown drafts (inherits to the
# folders below). archive/<party>/working/ carries its own
# history: true separately.
history: true
# Project members may CREATE here (so they can make their own
# working folders) but the file handler additionally restricts
# bare files at this root to the document controller — users put
# files inside a folder they created; the DC promotes/creates
# files at the root. The DC carries rwc so it can do both (and,
# unfenced, that authority cascades into every folder below).
acl:
permissions:
project_team: rc
document_controller: rwc
paths:
"*":
# A creator-owned working folder: <project>/working/<folder>/.
# auto_own (NOT auto_own_fenced) makes the creator the owner
# (rwcda + a .zddc they can edit) while leaving the folder
# readable to the rest of the project_team via the unfenced
# cascade above. Other members inherit rc here (read + add
# new files) but lack w/d, so they cannot modify or delete
# the owner's files; the owner narrows this in their own
# .zddc if they want it private.
default_tool: browse
available_tools: [browse]
auto_own: true
staging: staging:
default_tool: browse default_tool: browse
available_tools: [browse] available_tools: [browse]
@ -475,6 +509,13 @@ paths:
# homes below. # homes below.
auto_own: true auto_own: true
drop_target: true drop_target: true
# Edit-history: every markdown save under working/ (incl.
# the fenced per-user homes — history inherits through
# fences) is versioned into a sibling .history/ store with
# a server-stamped audit line (who + when). The live file
# stays the source of truth; GET <file>?history lists prior
# versions. See ZddcFile.History / handler.WriteTextWithHistory.
history: true
paths: paths:
"*": # per-user home dir, fenced "*": # per-user home dir, fenced
default_tool: browse default_tool: browse

View file

@ -84,13 +84,19 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
// //
// Canonical positions, relative to fsRoot: // Canonical positions, relative to fsRoot:
// //
// - <project>/archive (the only physical project-root canonical; // - <project>/archive (a physical project-root canonical)
// working/staging/reviewing/ssr/mdl/rsk at project root are virtual //
// aggregators with no on-disk presence — writes targeting them // - <project>/working (virtual at the slot level, but materialised
// must be rejected by the caller's project-root mkdir guard.) // on disk to host creator-owned working folders + document-
// controller files; created as a plain dir, never auto-owned)
//
// staging/reviewing/ssr/mdl/rsk at project root are virtual
// aggregators with no on-disk presence — writes targeting them are
// rejected here and by the caller's project-root mkdir guard.
//
// - <project>/archive/<party>/<canonical-party> where // - <project>/archive/<party>/<canonical-party> where
// <canonical-party> ∈ {mdl, rsk, incoming, received, issued, // <canonical-party> ∈ {mdl, rsk, incoming, received, issued,
// working, staging, reviewing} // working, staging, reviewing}
// //
// fsRoot and target must be absolute filesystem paths under the same // fsRoot and target must be absolute filesystem paths under the same
// volume; target may not yet exist on disk. // volume; target may not yet exist on disk.
@ -110,13 +116,24 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
return target, nil return target, nil
} }
// Reject writes targeting top-level virtual aggregators — // Project-root virtual aggregators. ssr/mdl/rsk are pure tables
// <project>/{ssr,mdl,rsk,working,staging,reviewing}/... — these // rollups and staging/reviewing are synthesised lifecycle windows —
// resolve through ResolveVirtualView, not as physical paths. A // none materialise on disk; row PUTs / lifecycle writes are rewritten
// caller writing under them bypassed the virtual resolver. // to canonical archive/<party>/ paths by ResolveVirtualView before
// reaching here, so a physical write under one of these means the
// resolver was bypassed.
//
// working/ is the exception: it is virtual at the slot level (the
// listing is synthesised from archive/*/working/), but it is also
// the home for creator-owned working folders at <project>/working/
// <folder>/. The slot dir is instantiated on disk the moment real
// content is created beneath it. Fall through to materialise it +
// the target's ancestors; WHO may write here (users create folders;
// only the document controller places files at the root) is gated in
// the file handler, not here.
if len(parts) >= 2 { if len(parts) >= 2 {
switch strings.ToLower(parts[1]) { switch strings.ToLower(parts[1]) {
case "ssr", "mdl", "rsk", "working", "staging", "reviewing": case "ssr", "mdl", "rsk", "staging", "reviewing":
return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1]) return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1])
} }
} }

View file

@ -197,12 +197,12 @@ func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) {
} }
} }
// Project-root virtual aggregator names are rejected — a write // Project-root virtual aggregator names (except working/) are rejected —
// targeting <project>/working/<...> bypasses the virtual resolver // a write targeting <project>/{staging,reviewing,ssr,mdl,rsk}/<...>
// and must not materialise on disk. // bypasses the virtual resolver and must not materialise on disk.
func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) { func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) {
root := t.TempDir() root := t.TempDir()
for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} { for _, slot := range []string{"staging", "reviewing", "ssr", "mdl", "rsk"} {
target := filepath.Join(root, "Proj", slot, "x.md") target := filepath.Join(root, "Proj", slot, "x.md")
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755) _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
if err == nil { if err == nil {
@ -214,6 +214,52 @@ func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) {
} }
} }
// working/ is the exception: it hosts creator-owned working folders, so
// real content beneath it instantiates the (otherwise virtual) slot dir
// on disk. The slot dir itself stays plain (never auto-owned); the
// creator-owned folder under it gets the unfenced auto-own .zddc.
func TestEnsureCanonicalAncestors_WorkingMaterialisesCreatorFolder(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "Proj", "working", "drafts", "notes.md")
if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil {
t.Fatalf("ensure: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); err != nil {
t.Errorf("working/ not created: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) {
t.Errorf("working/ slot dir must stay plain (no auto-own .zddc); got err=%v", err)
}
folderZddc := filepath.Join(root, "Proj", "working", "drafts", ".zddc")
data, err := os.ReadFile(folderZddc)
if err != nil {
t.Fatalf("creator folder auto-own .zddc missing: %v", err)
}
if !strings.Contains(string(data), "alice@x.com: rwcda") {
t.Errorf("creator folder auto-own missing creator grant: %s", data)
}
if strings.Contains(string(data), "inherit: false") {
t.Errorf("creator working folder must be UNFENCED (readable by the team); got: %s", data)
}
}
// A bare file directly at the project-level working/ root still
// materialises the slot dir — the file handler gates WHO may write it,
// not EnsureCanonicalAncestors.
func TestEnsureCanonicalAncestors_WorkingRootFileMaterialisesSlot(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "Proj", "working", "memo.md")
if _, err := EnsureCanonicalAncestors(root, target, "dc@x.com", 0o755); err != nil {
t.Fatalf("ensure: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); err != nil {
t.Errorf("working/ not created: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) {
t.Errorf("working/ slot dir must stay plain (no auto-own .zddc); got err=%v", err)
}
}
func TestEnsureCanonicalAncestors_RejectsTraversal(t *testing.T) { func TestEnsureCanonicalAncestors_RejectsTraversal(t *testing.T) {
root := t.TempDir() root := t.TempDir()
other := t.TempDir() other := t.TempDir()

View file

@ -263,6 +263,24 @@ type ZddcFile struct {
// not its descendants. Defaults (nil): no drop zone. // not its descendants. Defaults (nil): no drop zone.
DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"` DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"`
// History enables server-managed edit-history versioning for text
// (markdown) writes in this subtree. When true, each save of a
// history-eligible file (see handler.IsTextHistoryCandidate) snapshots
// the content into <dir>/.history/<stem>/ and appends a server-stamped
// audit line (timestamp + authenticated email) to that folder's
// log.jsonl, then writes the live file. The live file at its natural
// path stays the source of truth; the .history/ store is immutable and
// auto-hidden (dot-prefixed).
//
// Unlike DropTarget (leaf-only), History is a SUBTREE behavior: a
// `history: true` at an ancestor (e.g. archive/<party>/working/)
// applies to every descendant, and it deliberately inherits through
// inherit:false ACL fences (per-user homes under working/ still record
// history) — versioning is a write behavior, not a permission. A
// deeper level may set `history: false` to opt a subtree out. Resolved
// by PolicyChain.EffectiveHistory. Empty (nil) inherits via cascade.
History *bool `yaml:"history,omitempty" json:"history,omitempty"`
// Worm marks this directory (and its descendants) as // Worm marks this directory (and its descendants) as
// write-once-read-many. A non-nil Worm list — even an empty one — // write-once-read-many. A non-nil Worm list — even an empty one —
// puts the path into a WORM zone with these effects, applied AFTER // puts the path into a WORM zone with these effects, applied AFTER

View file

@ -0,0 +1,58 @@
package zddc
import "testing"
func bptr(b bool) *bool { return &b }
func TestEffectiveHistory(t *testing.T) {
tests := []struct {
name string
chain PolicyChain
want bool
}{
{
name: "no levels, no embedded default",
chain: PolicyChain{},
want: false,
},
{
name: "embedded default true, no explicit level",
chain: PolicyChain{Levels: []ZddcFile{{}, {}}, Embedded: ZddcFile{History: bptr(true)}},
want: true,
},
{
name: "ancestor true inherits down to leaf (nil)",
chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}, {}, {}}},
want: true,
},
{
name: "leaf false overrides ancestor true",
chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}, {History: bptr(false)}}},
want: false,
},
{
name: "leaf true overrides embedded false",
chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}}, Embedded: ZddcFile{History: bptr(false)}},
want: true,
},
{
// History must inherit THROUGH an inherit:false ACL fence —
// versioning is a write behavior, not a permission. The fenced
// middle level sets no History, so the ancestor's true wins.
name: "inherits through an inherit:false fence",
chain: PolicyChain{Levels: []ZddcFile{
{History: bptr(true)},
{ACL: ACLRules{Inherit: bptr(false)}},
{},
}},
want: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.chain.EffectiveHistory(); got != tc.want {
t.Errorf("EffectiveHistory() = %v, want %v", got, tc.want)
}
})
}
}

View file

@ -147,6 +147,35 @@ func VirtualAt(fsRoot, dirPath string) bool {
return false return false
} }
// HistoryAt reports whether edit-history versioning is enabled for
// writes in dirPath. Subtree-inheriting (see
// PolicyChain.EffectiveHistory) — a `history: true` at an ancestor
// applies here even through inherit:false fences.
func HistoryAt(fsRoot, dirPath string) bool {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return false
}
return chain.EffectiveHistory()
}
// IsRoleMemberAt reports whether email is a member of roleName as the
// role is visible (after fences / resets, unioned with the embedded
// defaults) at dirPath's cascade leaf. Returns false for an empty
// email or on cascade error. Used by the file handler to gate the few
// operations reserved for a standard role — e.g. only the
// document_controller may place files directly at the working/ root.
func IsRoleMemberAt(fsRoot, dirPath, roleName, email string) bool {
if email == "" {
return false
}
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil || len(chain.Levels) == 0 {
return false
}
return MatchesPrincipal(roleName, email, chain, len(chain.Levels)-1)
}
// IsDeclaredPath reports whether dirPath is mentioned in the // IsDeclaredPath reports whether dirPath is mentioned in the
// cascade — either by an on-disk .zddc at that level OR by any // cascade — either by an on-disk .zddc at that level OR by any
// ancestor's paths: tree (including the embedded defaults). // ancestor's paths: tree (including the embedded defaults).

View file

@ -48,6 +48,43 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
} }
} }
// TestHistoryAt_Defaults — the embedded convention enables edit-history
// versioning on BOTH (a) the project-level personal workspace
// <project>/working/ + its per-user <email>/ homes, and (b) the per-party
// archive/<party>/working/ + its homes. History is subtree-inheriting and
// ignores the homes' inherit:false fences. Sibling slots (staging,
// reviewing, mdl, incoming, received) do NOT get history.
func TestHistoryAt_Defaults(t *testing.T) {
resetCache()
root := t.TempDir()
cases := []struct {
path string
want bool
}{
// Project-level personal workspace.
{filepath.Join(root, "Project-X", "working"), true},
{filepath.Join(root, "Project-X", "working", "alice@example.com"), true},
{filepath.Join(root, "Project-X", "working", "alice@example.com", "notes"), true},
// Per-party working.
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true},
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes"), true},
// Sibling slots get no history.
{filepath.Join(root, "Project-X", "staging"), false},
{filepath.Join(root, "Project-X", "reviewing"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
{filepath.Join(root, "Project-X", "archive"), false},
}
for _, tc := range cases {
if got := HistoryAt(root, tc.path); got != tc.want {
t.Errorf("HistoryAt(%q) = %v, want %v", tc.path[len(root):], got, tc.want)
}
}
}
// TestDirToolAt — the trailing-slash form floors at "browse" for // TestDirToolAt — the trailing-slash form floors at "browse" for
// every path (the embedded convention sets dir_tool nowhere), and an // every path (the embedded convention sets dir_tool nowhere), and an
// on-disk .zddc can override it for a subtree. // on-disk .zddc can override it for a subtree.

View file

@ -88,6 +88,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
if top.DropTarget != nil { if top.DropTarget != nil {
out.DropTarget = top.DropTarget out.DropTarget = top.DropTarget
} }
if top.History != nil {
out.History = top.History
}
// Worm: presence (non-nil, even empty) marks the WORM zone. // Worm: presence (non-nil, even empty) marks the WORM zone.
// Concat-dedupe across levels (a deeper .zddc adds controllers); // Concat-dedupe across levels (a deeper .zddc adds controllers);
// preserve a non-nil empty slice so `worm: []` survives the // preserve a non-nil empty slice so `worm: []` survives the