feat(browse): party picker for New folder/file in virtual aggregators
Creating a folder/file at a project-level folder-nav aggregator root (working/staging/reviewing) used to error or silently shadow — the slots are virtual and content is party-scoped. Now browse opens a party picker that targets archive/<party>/<slot>/<name>, with a "+ New party…" option (server-gated to the document_controller via the existing archive/ ACL). - events.js: aggregatorRoot detection + openPartyPicker modal (mirrors the stage.js modal), createInAggregator routes the create to the canonical archive path; rewriteAggregatorPath handles right-clicking a party row shown in an aggregator listing so it never re-prompts. - server: serveFileMkdir now 409s a mkdir inside an aggregator (rejectProjectAggregatorMkdir) with a pointer at archive/<party>/<slot>/, instead of letting the write fall through to an unreachable shadow dir. Reverts the prior session's project-level creator-owned working/ folders (per the design decision to make all three folder-nav slots uniformly party-scoped): working/ is a pure virtual aggregator again like staging/reviewing — drops the working/ history+auto_own+acl defaults, the EnsureCanonicalAncestors working exception, the working-root document- controller file gate (serveFilePut/Move) and zddc.IsRoleMemberAt. Per-party archive/<party>/working/ keeps its own history + auto-own. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0a7f8594c5
commit
56c3353f7b
10 changed files with 281 additions and 277 deletions
|
|
@ -684,9 +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:
|
||||
|
||||
- **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>]`. (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.
|
||||
- **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `<project>/working`, `<project>/staging`, `<project>/reviewing`. All three are purely virtual (no on-disk presence); they 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>]`. 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.
|
||||
**Creating in an aggregator → party picker.** Because content is party-scoped, there's nowhere to put a folder/file at `<project>/<slot>/` itself. `EnsureCanonicalAncestors` rejects any physical write under all six aggregator names, and `serveFileMkdir` additionally 409s a mkdir *inside* one (`rejectProjectAggregatorMkdir`) with a message pointing at `archive/<party>/<slot>/` — rather than silently materialising an unreachable shadow folder. The browse "New folder" / "New file" action detects an aggregator root client-side (`events.js: aggregatorRoot`) and opens a **party picker** (`openPartyPicker`, styled like the stage modal): the user chooses an existing party — or "+ New party…", which issues `mkdir archive/<newparty>/…` and is gated to the `document_controller` by the existing `archive/` create ACL (a 403 surfaces a clear message). The chosen folder/file is then created at `archive/<party>/<slot>/<name>`, where the per-party `working/` slot carries its own `history: true` + auto-own convention. Right-clicking a party row shown in an aggregator listing rewrites to the canonical path directly (`rewriteAggregatorPath`), so it never re-prompts. (An earlier iteration made `<project>/working/` materialise project-level creator-owned folders directly; that was dropped in favour of this uniform party-scoped model.)
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -660,9 +660,186 @@
|
|||
return parentDir;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
}
|
||||
|
||||
// Valid party folder name — mirrors zddc.ValidPartyName server-side
|
||||
// (^[A-Za-z0-9][A-Za-z0-9.-]*$).
|
||||
function validPartyName(s) { return /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(s || ''); }
|
||||
|
||||
// The project-level folder-nav aggregators. These have no physical
|
||||
// presence: <project>/<slot>/ lists the parties whose
|
||||
// archive/<party>/<slot>/ has content. Creating something here means
|
||||
// creating it under a party — see createInAggregator.
|
||||
var FOLDER_NAV_SLOTS = { working: 1, staging: 1, reviewing: 1 };
|
||||
|
||||
// aggregatorRoot returns { project, slot } when parentDir is a
|
||||
// project-level folder-nav aggregator root (server mode only), else
|
||||
// null. parentDir is a "/<project>/<slot>/" URL.
|
||||
function aggregatorRoot(parentDir) {
|
||||
if (state.source !== 'server') return null;
|
||||
var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/');
|
||||
if (segs.length !== 2 || !segs[0]) return null;
|
||||
var slot = segs[1].toLowerCase();
|
||||
return FOLDER_NAV_SLOTS[slot] ? { project: segs[0], slot: slot } : null;
|
||||
}
|
||||
|
||||
// rewriteAggregatorPath maps a path UNDER a folder-nav aggregator
|
||||
// (a party already chosen — /<project>/<slot>/<party>[/<rest>]) to its
|
||||
// canonical archive path /<project>/archive/<party>/<slot>[/<rest>],
|
||||
// mirroring the server's folder-nav redirect. Returns null when
|
||||
// parentDir isn't under such an aggregator (root case is handled by
|
||||
// aggregatorRoot + the picker). Covers right-clicking a party row
|
||||
// shown in an aggregator listing so "New folder" doesn't 409.
|
||||
function rewriteAggregatorPath(parentDir) {
|
||||
var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/');
|
||||
if (segs.length < 3 || !segs[0]) return null;
|
||||
var slot = segs[1].toLowerCase();
|
||||
if (!FOLDER_NAV_SLOTS[slot]) return null;
|
||||
var p = '/' + segs[0] + '/archive/' + segs[2] + '/' + slot + '/';
|
||||
var rest = segs.slice(3);
|
||||
if (rest.length) p += rest.join('/') + '/';
|
||||
return p;
|
||||
}
|
||||
|
||||
// List the parties under a project's archive/ (folder names), sorted.
|
||||
async function fetchParties(project) {
|
||||
try {
|
||||
var entries = await loader.fetchServerChildren('/' + project + '/archive/');
|
||||
return entries
|
||||
.filter(function (e) { return e.isDir; })
|
||||
.map(function (e) { return e.name; })
|
||||
.sort(function (a, b) { return a.localeCompare(b); });
|
||||
} catch (_e) { return []; }
|
||||
}
|
||||
|
||||
// openPartyPicker resolves to { party, name } once the user picks a
|
||||
// party (existing or new) and a name, or null on cancel. Mirrors the
|
||||
// stage.js modal styling. New-party creation is offered but the server
|
||||
// gates it to the document_controller (a 403 surfaces a clear message).
|
||||
function openPartyPicker(opts) {
|
||||
return new Promise(function (resolve) {
|
||||
var kindWord = opts.kind === 'folder' ? 'folder' : 'file';
|
||||
var overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset: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.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);';
|
||||
|
||||
var partyList = opts.parties.map(function (name) {
|
||||
return '<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;">' +
|
||||
'<input type="radio" name="pp-party" value="' + escapeHtml(name) + '">' +
|
||||
'<span style="font-family:var(--code,monospace);">' + escapeHtml(name) + '</span></label>';
|
||||
}).join('');
|
||||
|
||||
box.innerHTML =
|
||||
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">New ' + kindWord + ' in ' + escapeHtml(opts.slot) + '/</h2>' +
|
||||
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
|
||||
escapeHtml(opts.slot) + '/ aggregates each party’s work, so it has no folder of its own. ' +
|
||||
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>archive/<party>/' + escapeHtml(opts.slot) + '/</code>.' +
|
||||
'</p>' +
|
||||
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
|
||||
(partyList || '<em style="color:#888;">No parties yet — create one below.</em>') +
|
||||
'<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' +
|
||||
'<input type="radio" name="pp-party" value="__new__">' +
|
||||
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">(document controller only)</span></span></label>' +
|
||||
'</div>' +
|
||||
'<div id="pp-newparty-row" style="display:none;margin-bottom:0.5rem;font-size:0.9rem;">' +
|
||||
'<label for="pp-newparty">New party name</label><br>' +
|
||||
'<input id="pp-newparty" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" placeholder="Acme">' +
|
||||
'</div>' +
|
||||
'<label for="pp-name" style="font-size:0.9rem;">' + (opts.kind === 'folder' ? 'Folder' : 'File') + ' name</label>' +
|
||||
'<input id="pp-name" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" value="' + (opts.kind === 'folder' ? 'new-folder' : 'new.md') + '">' +
|
||||
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
|
||||
'<button type="button" id="pp-cancel">Cancel</button>' +
|
||||
'<button type="button" id="pp-submit" class="btn-primary">Create</button>' +
|
||||
'</div>';
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
var newRow = box.querySelector('#pp-newparty-row');
|
||||
var newInput = box.querySelector('#pp-newparty');
|
||||
box.querySelectorAll('input[name="pp-party"]').forEach(function (r) {
|
||||
r.addEventListener('change', function () {
|
||||
var isNew = (r.value === '__new__' && r.checked);
|
||||
newRow.style.display = isNew ? '' : 'none';
|
||||
if (isNew) newInput.focus();
|
||||
});
|
||||
});
|
||||
|
||||
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
|
||||
function cancel() { close(); resolve(null); }
|
||||
box.querySelector('#pp-cancel').addEventListener('click', cancel);
|
||||
overlay.addEventListener('click', function (e) { if (e.target === overlay) cancel(); });
|
||||
box.querySelector('#pp-submit').addEventListener('click', function () {
|
||||
var sel = box.querySelector('input[name="pp-party"]:checked');
|
||||
if (!sel) { statusError('Pick a party.'); return; }
|
||||
var party;
|
||||
if (sel.value === '__new__') {
|
||||
party = newInput.value.trim();
|
||||
if (!validPartyName(party)) {
|
||||
statusError('Party name: a letter or digit, then letters/digits/dot/hyphen.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
party = sel.value;
|
||||
}
|
||||
var nv = validateName(box.querySelector('#pp-name').value);
|
||||
if (!nv.ok) { statusError(nv.msg); return; }
|
||||
close();
|
||||
resolve({ party: party, name: nv.name });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// createInAggregator routes a New folder/file in a virtual aggregator
|
||||
// root to archive/<party>/<slot>/<name> after prompting for the party.
|
||||
async function createInAggregator(agg, kind) {
|
||||
var up = window.app.modules.upload;
|
||||
if (!up) return;
|
||||
var parties = await fetchParties(agg.project);
|
||||
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties });
|
||||
if (!choice) return;
|
||||
// Party names are validated to a URL-safe charset, so no encoding
|
||||
// needed for the party segment; makeDir/makeFile encode the leaf.
|
||||
var targetDir = '/' + agg.project + '/archive/' + choice.party + '/' + agg.slot + '/';
|
||||
try {
|
||||
if (kind === 'folder') {
|
||||
await up.makeDir(targetDir, choice.name);
|
||||
statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + choice.name);
|
||||
} else {
|
||||
var name = /\.(md|markdown)$/i.test(choice.name) ? choice.name : choice.name + '.md';
|
||||
var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n';
|
||||
await up.makeFile(targetDir, name, template, 'text/markdown; charset=utf-8');
|
||||
statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + name);
|
||||
}
|
||||
} catch (e) {
|
||||
var msg = (e && e.message) || String(e);
|
||||
if (/\b403\b/.test(msg)) {
|
||||
statusError('Not allowed — creating a new party requires the document-controller role.');
|
||||
} else {
|
||||
statusError('Create failed: ' + msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Refresh the aggregator view — the party now appears if it had no
|
||||
// content before.
|
||||
await reloadDir('/' + agg.project + '/' + agg.slot + '/');
|
||||
}
|
||||
|
||||
async function createInDir(parentDir, kind) {
|
||||
var up = window.app.modules.upload;
|
||||
if (!up) return;
|
||||
// A project-level folder-nav aggregator (working/staging/reviewing)
|
||||
// has no physical home — route through the party picker instead of
|
||||
// erroring on an unplaceable mkdir/PUT.
|
||||
var agg = aggregatorRoot(parentDir);
|
||||
if (agg) return createInAggregator(agg, kind);
|
||||
// A party already chosen inside an aggregator view → canonical path.
|
||||
var rewritten = rewriteAggregatorPath(parentDir);
|
||||
if (rewritten) parentDir = rewritten;
|
||||
var promptMsg = kind === 'folder'
|
||||
? 'New folder name (under ' + parentDir + '):'
|
||||
: 'New markdown filename (under ' + parentDir + '):';
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ func TestDispatchAppsResolution(t *testing.T) {
|
|||
}
|
||||
// Create folder convention dirs so classifier/browse/transmittal
|
||||
// availability rules pass for the test paths used below.
|
||||
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
|
||||
mustMkdir(t, filepath.Join(root, "Project-A", "archive", "Acme", "working"))
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
if err != nil {
|
||||
|
|
@ -278,7 +278,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n permissions:\n \"*@example.com\": rwcd\n")
|
||||
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
|
||||
mustMkdir(t, filepath.Join(root, "Project-A", "archive", "Acme", "working"))
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
if err != nil {
|
||||
|
|
@ -298,11 +298,11 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|||
return req.WithContext(handler.WithEmail(req.Context(), email))
|
||||
}
|
||||
|
||||
// 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).
|
||||
// PUT a new file via dispatch. Content is party-scoped under
|
||||
// archive/<party>/working/ — the project-level working/ aggregator is
|
||||
// virtual (see TestFileAPI_MkdirInAggregatorRejected).
|
||||
body := []byte("note body")
|
||||
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/drafts/note.md", strings.NewReader(string(body))), "alice@example.com")
|
||||
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/archive/Acme/working/note.md", strings.NewReader(string(body))), "alice@example.com")
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
|
|
@ -310,7 +310,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|||
}
|
||||
|
||||
// GET it back.
|
||||
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/drafts/note.md", nil), "alice@example.com")
|
||||
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/archive/Acme/working/note.md", nil), "alice@example.com")
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
|
||||
|
|
@ -318,9 +318,9 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|||
}
|
||||
|
||||
// MOVE it.
|
||||
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/drafts/note.md", nil), "alice@example.com")
|
||||
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/archive/Acme/working/note.md", nil), "alice@example.com")
|
||||
req.Header.Set("X-ZDDC-Op", "move")
|
||||
req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/drafts/renamed.md")
|
||||
req.Header.Set("X-ZDDC-Destination", "/Project-A/archive/Acme/working/renamed.md")
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
|
|
@ -328,7 +328,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|||
}
|
||||
|
||||
// DELETE it.
|
||||
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/drafts/renamed.md", nil), "alice@example.com")
|
||||
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/archive/Acme/working/renamed.md", nil), "alice@example.com")
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
|
|
|
|||
|
|
@ -392,17 +392,6 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
|
||||
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) {
|
||||
return
|
||||
}
|
||||
|
|
@ -666,13 +655,6 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) {
|
||||
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) {
|
||||
return
|
||||
}
|
||||
|
|
@ -732,6 +714,15 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, why, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
// Mkdir INSIDE a virtual aggregator (working/staging/reviewing/…) has
|
||||
// no physical home — the content is party-scoped. 409 with a pointer
|
||||
// at archive/<party>/<slot>/ rather than silently creating an
|
||||
// unreachable shadow folder. (Browse's picker targets the archive/
|
||||
// path directly, so this is the bypass fallback.)
|
||||
if rejected, why := rejectProjectAggregatorMkdir(cfg.Root, abs); rejected {
|
||||
http.Error(w, why, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve canonical-folder casing on the way in (no side effects).
|
||||
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
|
||||
|
|
@ -828,26 +819,6 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
// Returns (true, reason) when the request should be 409'd. Returns
|
||||
// (false, "") when the target is at any other depth or carries an
|
||||
// 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) {
|
||||
rel, err := filepath.Rel(fsRoot, abs)
|
||||
if err != nil {
|
||||
|
|
@ -878,6 +849,38 @@ func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) {
|
|||
return true, "Conflict — only archive/ and system-reserved (_/. prefix) folders may be created directly under a project. Files belong inside archive/<party>/..."
|
||||
}
|
||||
|
||||
// rejectProjectAggregatorMkdir reports whether a mkdir lands INSIDE a
|
||||
// project-level virtual aggregator — <project>/{ssr,mdl,rsk,working,
|
||||
// staging,reviewing}/<name>[/...] (depth 3+). Those slots aggregate
|
||||
// per-party content; a folder created there has no physical home. The
|
||||
// real location is archive/<party>/<slot>/<name>, so we 409 with a
|
||||
// message pointing at the party-scoped path. (rejectProjectRootMkdir
|
||||
// handles the depth-2 case — the aggregator name itself.) The browse
|
||||
// "New folder" picker resolves the party client-side and targets the
|
||||
// archive/ path directly, so a user never trips this in normal use; it
|
||||
// is the clean fallback for a direct/scripted mkdir that bypassed it.
|
||||
func rejectProjectAggregatorMkdir(fsRoot, abs string) (bool, string) {
|
||||
rel, err := filepath.Rel(fsRoot, abs)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == "." || strings.HasPrefix(rel, "../") {
|
||||
return false, ""
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) < 3 {
|
||||
return false, "" // depth-2 (the slot itself) is rejectProjectRootMkdir's job
|
||||
}
|
||||
switch strings.ToLower(parts[1]) {
|
||||
case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
|
||||
slot := strings.ToLower(parts[1])
|
||||
return true, "Conflict — " + slot + "/ is a project-level virtual aggregator; folders here belong to a party. " +
|
||||
"Create it under archive/<party>/" + slot + "/ — browse's \"New folder\" picker prompts you for the party."
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// auditFile emits a structured log line for each file API operation.
|
||||
// AccessLogMiddleware already logs every request — this adds an
|
||||
// op-tagged line so audit consumers can filter by operation without
|
||||
|
|
|
|||
|
|
@ -721,103 +721,33 @@ func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
|
|||
// working/<email>/, the project-level pairing no longer maps cleanly.
|
||||
// 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)
|
||||
// Mkdir INSIDE a project-level virtual aggregator is 409'd with a
|
||||
// pointer at the party-scoped path, instead of silently materialising an
|
||||
// unreachable shadow. The same folder created under archive/<party>/
|
||||
// <slot>/ succeeds — which is what browse's party picker targets.
|
||||
func TestFileAPI_MkdirInAggregatorRejected(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} {
|
||||
rec := do(http.MethodPost, "/Proj/"+slot+"/foo/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("%s: mkdir in aggregator: want 409, got %d: %s", slot, rec.Code, rec.Body.String())
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", slot)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s: aggregator slot must not be materialised; got err=%v", slot, err)
|
||||
}
|
||||
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",
|
||||
// The party-scoped path the picker resolves to works.
|
||||
rec := do(http.MethodPost, "/Proj/archive/Acme/working/drafts/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("non-DC move to working/ root: want 403, got %d: %s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("party-scoped mkdir: want 201, 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)
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "Acme", "working", "drafts")); err != nil {
|
||||
t.Errorf("party-scoped folder not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,41 +206,13 @@ paths:
|
|||
working:
|
||||
default_tool: 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.)
|
||||
# Pure folder-nav aggregator over the per-party
|
||||
# archive/<party>/working/ slots — same shape as staging/ and
|
||||
# reviewing/ below. Nothing lives directly at <project>/working/:
|
||||
# creating a folder here prompts for a party (browse's "New
|
||||
# folder" picker) and lands it at archive/<party>/working/<name>,
|
||||
# which carries its own history: true + auto-own convention.
|
||||
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:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
|
|
|
|||
|
|
@ -84,15 +84,10 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
|
|||
//
|
||||
// Canonical positions, relative to fsRoot:
|
||||
//
|
||||
// - <project>/archive (a physical project-root canonical)
|
||||
//
|
||||
// - <project>/working (virtual at the slot level, but materialised
|
||||
// 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 (the only physical project-root canonical;
|
||||
// working/staging/reviewing/ssr/mdl/rsk at project root are virtual
|
||||
// aggregators with no on-disk presence — writes targeting them
|
||||
// must be rejected by the caller's project-root mkdir guard.)
|
||||
//
|
||||
// - <project>/archive/<party>/<canonical-party> where
|
||||
// <canonical-party> ∈ {mdl, rsk, incoming, received, issued,
|
||||
|
|
@ -116,24 +111,15 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
|||
return target, nil
|
||||
}
|
||||
|
||||
// Project-root virtual aggregators. ssr/mdl/rsk are pure tables
|
||||
// rollups and staging/reviewing are synthesised lifecycle windows —
|
||||
// none materialise on disk; row PUTs / lifecycle writes are rewritten
|
||||
// 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.
|
||||
// Reject writes targeting top-level virtual aggregators —
|
||||
// <project>/{ssr,mdl,rsk,working,staging,reviewing}/... — these
|
||||
// resolve through ResolveVirtualView, not as physical paths. A
|
||||
// caller writing under them bypassed the virtual resolver; the
|
||||
// content belongs under archive/<party>/<slot>/ (browse's "New
|
||||
// folder" picker prompts for the party).
|
||||
if len(parts) >= 2 {
|
||||
switch strings.ToLower(parts[1]) {
|
||||
case "ssr", "mdl", "rsk", "staging", "reviewing":
|
||||
case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
|
||||
return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,12 +197,12 @@ func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Project-root virtual aggregator names (except working/) are rejected —
|
||||
// a write targeting <project>/{staging,reviewing,ssr,mdl,rsk}/<...>
|
||||
// bypasses the virtual resolver and must not materialise on disk.
|
||||
// Project-root virtual aggregator names are rejected — a write
|
||||
// targeting <project>/working/<...> bypasses the virtual resolver
|
||||
// and must not materialise on disk.
|
||||
func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, slot := range []string{"staging", "reviewing", "ssr", "mdl", "rsk"} {
|
||||
for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} {
|
||||
target := filepath.Join(root, "Proj", slot, "x.md")
|
||||
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
||||
if err == nil {
|
||||
|
|
@ -214,52 +214,6 @@ 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) {
|
||||
root := t.TempDir()
|
||||
other := t.TempDir()
|
||||
|
|
|
|||
|
|
@ -159,23 +159,6 @@ func HistoryAt(fsRoot, dirPath string) bool {
|
|||
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
|
||||
// cascade — either by an on-disk .zddc at that level OR by any
|
||||
// ancestor's paths: tree (including the embedded defaults).
|
||||
|
|
|
|||
|
|
@ -49,10 +49,10 @@ 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,
|
||||
// versioning on the per-party archive/<party>/working/ subtree (+ its
|
||||
// homes). History is subtree-inheriting and ignores the homes'
|
||||
// inherit:false fences. The project-level working/ aggregator is virtual
|
||||
// (no direct content → no history), and sibling slots (staging,
|
||||
// reviewing, mdl, incoming, received) do NOT get history.
|
||||
func TestHistoryAt_Defaults(t *testing.T) {
|
||||
resetCache()
|
||||
|
|
@ -61,11 +61,10 @@ func TestHistoryAt_Defaults(t *testing.T) {
|
|||
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.
|
||||
// Project-level working/ is a pure virtual aggregator — no
|
||||
// direct content, so no history there.
|
||||
{filepath.Join(root, "Project-X", "working"), false},
|
||||
// Per-party working carries history (edit-history versioning).
|
||||
{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},
|
||||
|
|
|
|||
Loading…
Reference in a new issue