diff --git a/AGENTS.md b/AGENTS.md index 10c446e..26581f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -260,7 +260,7 @@ Format: `trackingNumber_revision (status) - title.extension` | Image | Chart pin | Embeds | |---|---|---| | Prod (Dockerfile.prod, BMCD) | `appVersion: "X.Y.Z"` → tag `zddc-server-v` | Stable-labeled bytes from the tagged release commit | -| Dev (Dockerfile, devshell) | `appVersion: "X.Y.Z"` or `"X.Y.Z-beta-"` → tag or SHA | Stable or beta-snapshot bytes (whichever the chart points at) | +| Dev (Dockerfile) | `appVersion: "X.Y.Z"` or `"X.Y.Z-beta-"` → tag or SHA | Stable or beta-snapshot bytes (whichever the chart points at) | | Local dev iteration | n/a | Use `tool/dist/.html` directly; binary's embedded copy lags | On-page `{{BUILD_LABEL}}` format (HTML tools only — zddc-server's version comes from the binary itself): @@ -401,7 +401,7 @@ Read/aggregate counterpart to the form system. Renders a directory of YAML row f - **Nested sub-tables** — `/sub-list/table.yaml` is its own self-contained table at `/sub-list/table.html`. Composition, not violation. - **Per-row attachments** — `/.attachments/file.pdf`. Natural sidecar pattern; the row YAML can reference its attachments by relative path. - **Drafts / staging** — `/.drafts/.yaml` (dot-prefix → hidden from listings as well as from the table). -- **Per-row history** — `/.history//-.yaml`. Server-managed; one directory per record, one file per archived revision. See "Records, audit, and history" below. +- **Per-row history** — `/.zddc.d/history//-.yaml`. Server-managed; one directory per record, one file per archived revision. See "Records, audit, and history" below. **Default-MDL fallback at `archive//mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive//mdl/`, presence-based discovery is the rule. @@ -433,7 +433,7 @@ Defaults are baked into `defaults.zddc.yaml`; `field_codes:` ships empty (every - `revision` — `1` on create, `+1` per update - `previous_sha` — first 8 hex chars of SHA-256 of the prior revision's bytes; absent on create. Forms a hash chain for tamper evidence -**History layout**: for any record at `/.`, the prior version is archived at `/.history//-.` before the live file is overwritten. Per-record subfolder under `.history/` keeps `readdir` cheap and makes party-folder rename move SSR history along atomically (the dot-folder is inside the party folder, so `os.Rename` carries it). +**History layout**: for any record at `/.`, the prior version is archived at `/.zddc.d/history//-.` before the live file is overwritten. Per-record subfolder under `.zddc.d/history/` keeps `readdir` cheap and makes party-folder rename move SSR history along atomically (the dot-folder is inside the party folder, so `os.Rename` carries it). **Write ordering**: history first, then live. A crash between the two leaves the prior version safely archived; the retry is idempotent because the history filename is deterministic (timestamp + sha of prior bytes). @@ -746,7 +746,7 @@ local path that fails loudly and visibly on the developer's terminal. - Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two entries per tracking number: `.html` (highest base rev) and `_.html` (each specific base rev). Both **serve in place** — the handler streams the first chronologically received copy's bytes back at the `.archive/` URL without redirecting. The per-transmittal URL is intentionally hidden so external links of the form `.archive/.html#section` keep tracking the latest revision (a redirect would expose the snapshot URL and people would forward THAT instead). Cache-Control is `no-cache` so each load revalidates against the on-disk file's Last-Modified/ETag; when a new revision lands the resolver picks it and the browser refetches. Modifier files (`_+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree. - ACL is enforced via cascading `.zddc` YAML files — first-explicit-match-wins evaluated bottom-up (deepest level first), with deny checked before allow within a single `.zddc`; default-deny when any `.zddc` exists in the chain. Authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`). Operator-facing detail, anti-patterns, worked layouts, the verify-it-works recipe, and the federal-readiness gap analysis are in `zddc/README.md` § "Access control: the `.zddc` cascade." The architectural framing (cooperating layers, commercial-vs-federal trust model, why archive auto-serves at every directory) is in `ARCHITECTURE.md` § "Server security model." - `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page". -- `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by the dev-shell pod's Caddy to gate `/devshell/*` (code-server) on root-admin status without code-server learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint. +- `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by an upstream proxy to gate an admin-only sub-app on root-admin status without that app learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint. - **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like the `_template/` directory created by the self-contained install snippet, still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable. - **Caching on embedded tool HTMLs** (landing, browse served at `/`, plus the five canonical app HTMLs at `/.html`): `Cache-Control: public, max-age=0, must-revalidate` + content-addressed `ETag` (sha256 hex prefix). Browser revalidates on every load; matching ETag returns `304 Not Modified` with empty body. ETag changes only when the binary is redeployed (computed once at startup from `EmbeddedBytes` + `BuildVer`, memoized). - **Compression**: gzip middleware (`github.com/klauspost/compress/gzhttp`) wraps the entire mux. Skipped for bodies under 1 KB and for 304 responses. Roughly 75% size reduction on tool HTMLs and JSON listings. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0f8489d..a014eff 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -470,7 +470,7 @@ app.state.subscribe((property, newValue) => { **Server-side counterpart:** `zddc/internal/handler/formhandler.go` recognizes `*.form.html` and `*.yaml.html` URLs, parses the spec, validates submissions via `zddc/internal/jsonschema/`, writes via `zddc.WriteAtomic` (plain submissions) or `zddc/internal/handler/history.go` `WriteWithHistory` (record-typed YAML — mdl rows, rsk rows, ssr.yaml). Existence of `.form.yaml` is the trigger; without it, the URL falls through to static-file serving. -**Record-vs-submission distinction.** "Records" are the three table-store types (mdl/rsk/ssr); everything else is a "submission." Records get server-stamped audit fields (`created_at`/`_by`, `updated_at`/`_by`, `revision`, `previous_sha`), an immutable per-record history at `/.history//-.`, cascade-driven filename composition (via the `records:` + `field_codes:` `.zddc` keys), per-folder field locking (e.g. type=RSK in rsk/), and folder-bound fields (`folder_fields`, e.g. originator = party-folder name). The mechanism intercepts at every write entry point — the file-API `serveFilePut` (if `isRecordPath` matches → `WriteWithHistory`, else `WriteAtomic`), the in-dir form create/update (`serveFormCreate`/`serveFormUpdate`), and the project rollup (`serveFormCreateRollup`). Each resolves the `records:` rule for the target directory and, when one with a `filename_format` applies, composes the name via the shared `recordCreatePrep` and routes through `WriteWithHistory`; non-record paths keep the historical date+email `WriteAtomic` write. The convergence means there's no back door that writes an un-stamped, un-composed record. All of it is server-side: the tools opened offline (`file://` / FS-Access, no server) can't enforce audit, composition, `field_codes`, or `folder_fields` — record writes need zddc-server. See AGENTS.md "Records, audit, and history" for the operator surface (incl. the offline gap and pre-folder-binding upgrade notes); `zddc/internal/handler/history.go` for the orchestration. +**Record-vs-submission distinction.** "Records" are the three table-store types (mdl/rsk/ssr); everything else is a "submission." Records get server-stamped audit fields (`created_at`/`_by`, `updated_at`/`_by`, `revision`, `previous_sha`), an immutable per-record history at `/.zddc.d/history//-.`, cascade-driven filename composition (via the `records:` + `field_codes:` `.zddc` keys), per-folder field locking (e.g. type=RSK in rsk/), and folder-bound fields (`folder_fields`, e.g. originator = party-folder name). The mechanism intercepts at every write entry point — the file-API `serveFilePut` (if `isRecordPath` matches → `WriteWithHistory`, else `WriteAtomic`), the in-dir form create/update (`serveFormCreate`/`serveFormUpdate`), and the project rollup (`serveFormCreateRollup`). Each resolves the `records:` rule for the target directory and, when one with a `filename_format` applies, composes the name via the shared `recordCreatePrep` and routes through `WriteWithHistory`; non-record paths keep the historical date+email `WriteAtomic` write. The convergence means there's no back door that writes an un-stamped, un-composed record. All of it is server-side: the tools opened offline (`file://` / FS-Access, no server) can't enforce audit, composition, `field_codes`, or `folder_fields` — record writes need zddc-server. See AGENTS.md "Records, audit, and history" for the operator surface (incl. the offline gap and pre-folder-binding upgrade notes); `zddc/internal/handler/history.go` for the orchestration. **Round-trip philosophy:** v0 is "form-as-truth" — submission YAML is regenerated from form state on every save. Hand-edits to submission files are not preserved across re-edit→re-submit. v1 will add an opt-in "file-as-truth" mode (eemeli/yaml Document API) for forms like `.zddc` itself where users hand-edit and comments must survive. diff --git a/browse/js/history.js b/browse/js/history.js index e4747b8..6428a66 100644 --- a/browse/js/history.js +++ b/browse/js/history.js @@ -6,8 +6,8 @@ // equivalent. // // Talks to the zddc-server history endpoints on the file's own URL: -// GET ?history=1 → JSON [{ts, by, sha, prev, bytes, current}] -// GET ?history= → that version's raw bytes +// GET ?history=1 → JSON [{ts, by, id, bytes, current}] +// GET ?history= → that version's raw bytes (id = snapshot filename) // Restore re-PUTs a chosen version's bytes to , which the server // records as a new version (forward-only; never destructive). // @@ -67,8 +67,8 @@ return Array.isArray(data) ? data : []; } - async function fetchVersion(node, sha) { - var resp = await fetch(histURL(node.url, sha), { credentials: 'same-origin' }); + async function fetchVersion(node, id) { + var resp = await fetch(histURL(node.url, id), { credentials: 'same-origin' }); if (!resp.ok) throw new Error('HTTP ' + resp.status); return await resp.text(); } @@ -171,17 +171,17 @@ cb.className = 'md-history-pick'; cb.addEventListener('change', function () { if (cb.checked) { - selected.push(ent.sha); + selected.push(ent.id); // 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; + if (o !== cb && entries[i] && entries[i].id === dropped) o.checked = false; }); } } else { - selected = selected.filter(function (s) { return s !== ent.sha; }); + selected = selected.filter(function (s) { return s !== ent.id; }); } syncDiffBtn(); }); @@ -220,7 +220,7 @@ 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; }); + var picks = entries.filter(function (e) { return selected.indexOf(e.id) !== -1; }); picks.sort(function (a, b) { return (a.ts < b.ts ? -1 : 1); }); renderDiff(modal, node, picks[0], picks[1], entries); } @@ -237,7 +237,7 @@ body.innerHTML = '

Loading…

'; var text; try { - text = await fetchVersion(node, ent.sha); + text = await fetchVersion(node, ent.id); } catch (e) { body.innerHTML = ''; var err = document.createElement('p'); @@ -274,8 +274,8 @@ body.innerHTML = '

Loading…

'; var oldText, newText; try { - oldText = await fetchVersion(node, oldEnt.sha); - newText = await fetchVersion(node, newEnt.sha); + oldText = await fetchVersion(node, oldEnt.id); + newText = await fetchVersion(node, newEnt.id); } catch (e) { body.innerHTML = ''; var err = document.createElement('p'); @@ -347,7 +347,7 @@ return; } try { - var text = await fetchVersion(node, ent.sha); + var text = await fetchVersion(node, ent.id); var resp = await fetch(node.url, { method: 'PUT', credentials: 'same-origin', diff --git a/shared/elevation.js b/shared/elevation.js index f53c1b9..326c3d1 100644 --- a/shared/elevation.js +++ b/shared/elevation.js @@ -1,20 +1,23 @@ -// shared/elevation.js — admin elevation toggle. +// shared/elevation.js — admin elevation via URL toggle. // -// Sudo-style model: admins behave as normal users by default; clicking -// the header toggle elevates the session so admin escape hatches (WORM -// bypass, .zddc edit authority, profile admin scaffolds) start firing. -// State is carried in a `zddc-elevate=1` cookie that the server reads -// via handler.ACLMiddleware → zddc.Principal{Elevated}. +// Sudo-style model: admins behave as normal users by default; elevating +// the session turns on admin escape hatches (WORM bypass, .zddc edit +// authority, profile admin scaffolds). State is carried in a +// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware +// → zddc.Principal{Elevated}. // -// Only renders the toggle when /.profile/access reports the caller has -// some admin scope — a non-admin sees nothing, which keeps the chrome -// quiet for the common case. The toggle fades in once access loads so -// non-admins never even see the affordance flash. +// Toggle is by URL query param — `?admin=true` to arm, `?admin=false` +// (or the red banner's "Drop admin" button) to drop — so it's reachable +// from ANY zddc-server page, not just ones that render a header control. +// The cookie is the sticky state: it persists across navigation for its +// Max-Age window, so the param need not stay in the URL (we strip it). +// Arming is gated on /.profile/access `can_elevate`, so only real admins +// can set it; a non-admin's ?admin=true is a silent no-op. // -// Click flow: set/clear the cookie, then reload the page so the server -// sees the new state on the next render. The reload is intentional — -// admin scaffolds in tool HTML are server-rendered for some tools, so -// a soft state flip on the client alone wouldn't reach those. +// Applying the cookie reloads to the cleaned URL so the server re-renders +// under the new state (admin scaffolds in some tool HTML are server- +// rendered, so a client-only flip wouldn't reach them). The red viewport +// border + banner (applyArmedChrome) reflect the cookie on every load. (function () { 'use strict'; @@ -59,22 +62,65 @@ } } - function render(host, elevated) { - host.classList.remove('hidden'); - host.innerHTML = - '' - + ''; - var cb = host.querySelector('#elevation-checkbox'); - cb.addEventListener('change', function () { - setElevated(cb.checked); - // Hard reload so server-rendered admin surfaces (profile - // page scaffolds, hidden-entry listings) catch up. URL - // and scroll state are preserved by the browser's normal - // back-forward cache rules. - window.location.reload(); - }); + // ── URL toggle: ?admin=true | ?admin=false (typeable anywhere) ────── + // + // Admin mode is toggled via a URL query param rather than an on-screen + // checkbox, so it's reachable from any zddc-server page. The param only + // SETS the cookie; the cookie is the sticky state (it persists across + // navigation for its Max-Age window and is what the server reads), so + // there's no need to keep ?admin= in the URL once applied. + + // adminParam returns true/false for a recognised ?admin= value, or null + // when absent / unrecognised (ignored). + function adminParam() { + try { + var v = new URLSearchParams(window.location.search).get('admin'); + if (v === null) return null; + v = v.toLowerCase(); + if (v === 'true' || v === '1' || v === 'on' || v === 'yes') return true; + if (v === 'false' || v === '0' || v === 'off' || v === 'no') return false; + return null; + } catch (_e) { return null; } + } + + // urlWithoutAdmin returns the current URL with the admin param stripped + // (other params + hash preserved) — what we navigate/replace to so the + // dirty param isn't bookmarked and Back doesn't re-trigger it. + function urlWithoutAdmin() { + var u = new URL(window.location.href); + u.searchParams.delete('admin'); + var qs = u.searchParams.toString(); + return u.pathname + (qs ? '?' + qs : '') + u.hash; + } + + // handleAdminParam applies a ?admin= request. Returns true when a + // navigation (reload) is underway so the caller can stop. Enabling is + // gated on can_elevate — a non-admin who types ?admin=true just gets + // the param stripped, never a misleading red border. Disabling is open + // (anyone may drop a cookie they somehow hold). + async function handleAdminParam() { + var want = adminParam(); + if (want === null) return false; + var clean = urlWithoutAdmin(); + if (want === isElevated()) { + // Already in the requested state — just clean the URL, no reload. + try { history.replaceState(history.state, '', clean); } catch (_e) {} + return false; + } + if (want === true) { + var access = await fetchAccess(); + if (!access || !access.can_elevate) { + try { history.replaceState(history.state, '', clean); } catch (_e) {} + return false; + } + setElevated(true); + } else { + setElevated(false); + } + // Navigate to the clean URL (a real load, so the server re-renders + // under the new cookie) and replace history so Back is safe. + window.location.replace(clean); + return true; } // Page-wide affordances when elevation is active. The toggle alone @@ -116,26 +162,16 @@ } async function init() { - // Body chrome applies on every page load whether or not the - // header has a toggle slot — the banner needs to surface in - // tools / pages that don't host the toggle (e.g. iframed - // classifier inside browse's grid mode), so the user can't - // accidentally write through an elevated context elsewhere. + // Apply (or tear down) the red border + banner from the cookie on + // every page load — admin mode is toggled by URL, but the armed + // chrome must surface everywhere so the user can't accidentally + // write through an elevated context on a page they didn't toggle. applyArmedChrome(isElevated()); - var host = document.getElementById('elevation-toggle'); - if (!host) return; // tool doesn't include the slot yet — no-op - var access = await fetchAccess(); - if (!access) return; // anonymous / endpoint missing — no-op - // Surface ONLY for users who have admin authority somewhere. - // /.profile/access ships `can_elevate` as an elevation- - // INDEPENDENT signal — true for any user named in any admin - // list, regardless of current cookie state. The other flags - // (is_super_admin, has_any_admin_scope) reflect EFFECTIVE - // authority and would be false for an un-elevated admin - // who hasn't toggled yet — so we can't gate on those. - if (!access.can_elevate) return; - render(host, isElevated()); + // Honour ?admin=true|false typed into any zddc-server URL. There's + // no on-screen toggle anymore — the URL is the enable path and the + // red banner's "Drop admin" button is the one-click disable. + await handleAdminParam(); } if (document.readyState === 'loading') { diff --git a/zddc/README.md b/zddc/README.md index 16831ec..7b3b882 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -796,7 +796,7 @@ there at all in the worked example), and no other vendor's name leaks via listin Two prefixes are filtered from listings under `ZDDC_ROOT`: -- **`.`-prefixed** (e.g. `/.devshell/`, `/Project-A/.internal/notes.md`) — excluded +- **`.`-prefixed** (e.g. `/.zddc.d/`, `/Project-A/.internal/notes.md`) — excluded from listings **and** 404 on direct HTTP access. The recognized virtual prefixes (`.archive`, `.admin`) are explicitly permitted through. This lets operators store side-state (caches, dev-shell home dirs, snapshot staging) on the same volume @@ -1489,13 +1489,13 @@ The intended use case is gating *adjacent* services on the same pod / host that don't have their own ACL. Concretely: the dev-shell deployment runs both `zddc-server` and `code-server` behind one Caddy listener; Caddy uses `forward_auth` to ask `/.auth/admin` whether the caller is allowed to reach -`/devshell/*` (the IDE) before forwarding. zddc-server's own routes (`/`, +an admin-only sub-app before forwarding. zddc-server's own routes (`/`, `//`, `/.archive/`, etc.) keep their existing `.zddc`-cascade ACL and don't go through this endpoint. ```caddy -# example: protect /devshell/* with forward_auth on /.auth/admin -handle_path /devshell/* { +# example: protect an admin-only sub-app with forward_auth on /.auth/admin +handle_path /admin-app/* { forward_auth 127.0.0.1:9090 { uri /.auth/admin copy_headers X-Auth-Request-Email diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index a99ac87..108a568 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -169,7 +169,6 @@ func main() { slog.Info("archive periodic rescan disabled (interval=0)") } - // HTTP handler mux := http.NewServeMux() // Middleware chain (outermost → innermost): @@ -826,11 +825,14 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // Reserve dot-prefixed path segments. The listing pipeline already hides // hidden entries (internal/fs/tree.go:90, projectshandler.go:40), - // but direct URL access would still serve them. 404 here so hidden trees - // like /srv/.devshell (the in-image dev-shell's persistent home dir on - // the same Azure Files PVC as served data) cannot be fetched. The - // recognized virtual prefixes (.profile handled above, cfg.IndexPath - // handled below) are explicitly allowed through. + // but direct URL access would still serve them. 404 here so server + // bookkeeping under the reserved .zddc.d/ sidecar (tokens, history, …) + // cannot be fetched raw. The recognized virtual prefixes (.profile + // handled above, cfg.IndexPath handled below) are explicitly allowed + // through. + // + // (Part B will replace this blanket block with a .zddc.d/ admin-fence so + // dot-content is uniformly ACL-governed; until then the block stands.) // // Also reserve the apps cache directory (`_app`): the cached HTML files // there must be served via the apps resolver (with proper headers and @@ -1332,12 +1334,13 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // 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) - } + if handler.IsTextHistoryCandidate(cfg.Root, absPath) { + // Reading recorded history does NOT require history to be + // currently enabled — snapshots already on disk stay readable + // (empty list when there are none) even if the `history:` flag + // was later turned off. The file's read ACL was already checked + // above; WRITES remain gated by EffectiveHistory in serveFilePut. + handler.ServeTextHistory(w, r, cfg.Root, absPath, version) return } handler.ServeHistoryList(w, r, absPath) diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 6e294ff..acb55ed 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -26,20 +26,18 @@ import ( // rejects requests whose URL contains a dot-prefixed segment (other than // the recognized virtual prefixes .archive and /.profile handled separately). // -// The guard exists so the in-image dev-shell can keep persistent state -// (settings, source clones, Go module cache) under /srv/.devshell on the -// same Azure Files PVC as served data without ever exposing those files -// via direct HTTP fetch. +// The guard keeps server bookkeeping under the reserved .zddc.d/ sidecar +// (tokens, history, …) from being fetched raw over HTTP. (Part B will +// replace this blanket block with a .zddc.d/ admin-fence.) func TestDispatchHidesDotPrefixedSegments(t *testing.T) { root := t.TempDir() - // Realistic shape: a project dir, a hidden top-level dir, and a hidden - // sibling of a normal file inside the project. + // Realistic shape: a project dir, a reserved .zddc.d/ token store, and a + // hidden sibling of a normal file inside the project. mustMkdir(t, filepath.Join(root, "Project-A")) mustWrite(t, filepath.Join(root, "Project-A", "doc.txt"), "ok") - mustMkdir(t, filepath.Join(root, ".devshell")) - mustMkdir(t, filepath.Join(root, ".devshell", "coder")) - mustWrite(t, filepath.Join(root, ".devshell", "coder", "settings.json"), "secret") + mustMkdir(t, filepath.Join(root, ".zddc.d", "tokens")) + mustWrite(t, filepath.Join(root, ".zddc.d", "tokens", "abc123"), "secret") mustMkdir(t, filepath.Join(root, "Project-A", ".internal")) mustWrite(t, filepath.Join(root, "Project-A", ".internal", "notes.md"), "secret") @@ -60,9 +58,9 @@ func TestDispatchHidesDotPrefixedSegments(t *testing.T) { path string wantStatus int }{ - // Hidden top-level dir — every shape blocked. - {"hidden top dir", "/.devshell/", http.StatusNotFound}, - {"hidden top dir nested", "/.devshell/coder/settings.json", http.StatusNotFound}, + // Reserved .zddc.d/ bookkeeping — every shape blocked. + {"reserved .zddc.d dir", "/.zddc.d/", http.StatusNotFound}, + {"reserved .zddc.d token", "/.zddc.d/tokens/abc123", http.StatusNotFound}, // Hidden segment under a real project dir — also blocked. {"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound}, @@ -336,11 +334,11 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } // Reserved segment guard still applies to writes. - req = withEmail(httptest.NewRequest(http.MethodPut, "/.devshell/foo.txt", strings.NewReader("x")), "alice@example.com") + req = withEmail(httptest.NewRequest(http.MethodPut, "/.zddc.d/foo.txt", strings.NewReader("x")), "alice@example.com") rec = httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusNotFound { - t.Fatalf("PUT /.devshell/...: want 404, got %d", rec.Code) + t.Fatalf("PUT /.zddc.d/...: want 404, got %d", rec.Code) } } @@ -1065,3 +1063,9 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) { } }) } + +// (TestDispatchHistoryReadCarveOut was removed: history snapshots now live +// under the reserved .zddc.d/history/ namespace — blocked raw by the +// dot-prefix guard, like any bookkeeping, and surfaced only through the +// history endpoints. Raw-block coverage is in TestDispatchHidesDotPrefixedSegments; +// the viewer is covered in mdhistory_test.go.) diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index e0bc0cb..50de8f7 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -432,7 +432,7 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) { } finalBody = res.FinalBody stamped = true - } else if IsTextHistoryCandidate(abs) && zddc.HistoryAt(cfg.Root, filepath.Dir(abs)) { + } else if IsTextHistoryCandidate(cfg.Root, abs) && zddc.HistoryAt(cfg.Root, filepath.Dir(abs)) { // History-enabled text (markdown) files: snapshot every save // into /.history// with a server-stamped audit line, // then write the live file. The live file at its natural path @@ -686,6 +686,24 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) { purgeConverted(srcAbs) purgeConverted(dstAbs) + // Carry edit-history across an in-place rename: if a markdown file was + // renamed within the same directory, move its .history// folder to + // match the new name. A cross-directory move deliberately leaves history + // behind (it lives forever in the dir where the edits happened). + if IsTextHistoryCandidate(cfg.Root, srcAbs) && filepath.Dir(srcAbs) == filepath.Dir(dstAbs) { + oldHist := mdHistoryDir(srcAbs) + newHist := mdHistoryDir(dstAbs) + if oldHist != newHist { + if _, err := os.Stat(oldHist); err == nil { + if _, derr := os.Stat(newHist); errors.Is(derr, os.ErrNotExist) { + if rerr := os.Rename(oldHist, newHist); rerr != nil { + slog.Warn("rename history dir", "from", oldHist, "to", newHist, "err", rerr) + } + } + } + } + } + // Compute new ETag from the moved bytes for the response — clients // that want to keep tracking should pin to this ETag. if etag, err := fileETagOnDisk(dstAbs); err == nil && etag != "" { @@ -842,8 +860,7 @@ func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) { return false, "" } lower := strings.ToLower(name) - switch lower { - case "ssr", "mdl", "rsk", "working", "staging", "reviewing": + if zddc.IsVirtualAggregatorSlot(lower) { return true, "Conflict — " + lower + "/ is a project-level virtual aggregator and cannot be created as a physical folder. Files of this kind live under archive//" + lower + "/." } return true, "Conflict — only archive/ and system-reserved (_/. prefix) folders may be created directly under a project. Files belong inside archive//..." @@ -872,9 +889,7 @@ func rejectProjectAggregatorMkdir(fsRoot, abs string) (bool, string) { 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]) + if slot := strings.ToLower(parts[1]); zddc.IsVirtualAggregatorSlot(slot) { return true, "Conflict — " + slot + "/ is a project-level virtual aggregator; folders here belong to a party. " + "Create it under archive//" + slot + "/ — browse's \"New folder\" picker prompts you for the party." } diff --git a/zddc/internal/handler/fileapi_test.go b/zddc/internal/handler/fileapi_test.go index 14c5a70..07c11af 100644 --- a/zddc/internal/handler/fileapi_test.go +++ b/zddc/internal/handler/fileapi_test.go @@ -751,3 +751,52 @@ func TestFileAPI_MkdirInAggregatorRejected(t *testing.T) { t.Errorf("party-scoped folder not created: %v", err) } } + +// An in-place rename of a markdown file carries its .history// folder +// to the new name; a cross-directory move leaves history behind. +func TestFileAPI_MoveRenamesHistoryDir(t *testing.T) { + _, do, root := fileAPITestSetup(t, nil, map[string]string{ + "Docs/notes.md": "# notes\n", + }) + // Pre-seed a history snapshot for notes.md. + histOld := filepath.Join(root, "Docs", ".zddc.d", "history", "notes") + if err := os.MkdirAll(histOld, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(histOld, "20260101T000000.000Z-a@example.com.md"), []byte("# notes\n"), 0o644); err != nil { + t.Fatal(err) + } + + // In-place rename → history dir follows. + rec := do(http.MethodPost, "/Docs/notes.md", "alice@example.com", nil, map[string]string{ + "X-ZDDC-Op": "move", + "X-ZDDC-Destination": "/Docs/renamed.md", + }) + if rec.Code != http.StatusOK { + t.Fatalf("rename: want 200, got %d: %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "Docs", ".zddc.d", "history", "renamed")); err != nil { + t.Errorf("history dir not renamed to : %v", err) + } + if _, err := os.Stat(histOld); !os.IsNotExist(err) { + t.Errorf("old history dir should be gone after rename; err=%v", err) + } + + // Cross-directory move → history stays behind in the source dir. + if err := os.MkdirAll(filepath.Join(root, "Other"), 0o755); err != nil { + t.Fatal(err) + } + rec = do(http.MethodPost, "/Docs/renamed.md", "alice@example.com", nil, map[string]string{ + "X-ZDDC-Op": "move", + "X-ZDDC-Destination": "/Other/renamed.md", + }) + if rec.Code != http.StatusOK { + t.Fatalf("cross-dir move: want 200, got %d: %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "Docs", ".zddc.d", "history", "renamed")); err != nil { + t.Errorf("history should stay behind on a cross-dir move: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "Other", ".zddc.d", "history", "renamed")); !os.IsNotExist(err) { + t.Errorf("history should NOT follow a cross-dir move; err=%v", err) + } +} diff --git a/zddc/internal/handler/history.go b/zddc/internal/handler/history.go index 46d2d45..5ff1d84 100644 --- a/zddc/internal/handler/history.go +++ b/zddc/internal/handler/history.go @@ -9,7 +9,7 @@ // // 2. Prior bytes are preserved. Before the live file is overwritten // the previous content is copied (byte-for-byte) into -// /.history//-.. The +// /.zddc.d/history//-.. The // filename embeds the timestamp + the SHA-256 prefix of the prior // bytes — the same value that's stamped into the new record's // previous_sha field — so the chain is auditable. @@ -28,6 +28,7 @@ package handler import ( + "bytes" "crypto/sha256" "encoding/hex" "encoding/json" @@ -59,11 +60,14 @@ const ( auditFieldPreviousSha = "previous_sha" ) -// historyDirName is the dot-prefixed bookkeeping folder under each -// record-containing directory. resolveTargetPath's dot-segment -// rejection means no client URL can reach into .history/ — only the -// server's own history-write code path touches it. -const historyDirName = ".history" +// historyDirRel is where edit-history snapshots live under each +// history-tracked directory: /.zddc.d/history//. It sits under +// the single reserved .zddc.d/ sidecar namespace (all zddc bookkeeping), so +// it is reached only through the history endpoints (records: +// .yaml?history=1; text: ?history=…), which read it +// server-side — never as raw browsable content. Both the record and text +// history paths share this base. +const historyDirRel = ".zddc.d/history" // WriteRecordResult carries what serveFilePut needs to surface a // response after a successful record write. @@ -80,8 +84,8 @@ type WriteRecordResult struct { // Returns: // - res, nil, nil: success; caller writes 200/201 + ETag. // - _, errs, nil: 422 with the validation errors (locked -// mismatch, field_code violation, filename -// composition mismatch). +// mismatch, field_code violation, filename +// composition mismatch). // - _, _, err: internal error; caller writes 500. // // The function does NOT do ACL, ETag-precondition, or canonical- @@ -261,7 +265,7 @@ func WriteWithHistory(cfg config.Config, abs, cleanURL string, body []byte, prin // (timestamp+sha8 of priorBody) — rewriting it idempotently // is harmless when the live write later succeeds. if priorExisted { - histDir := filepath.Join(dir, historyDirName, stripExt(base)) + histDir := filepath.Join(dir, historyDirRel, stripExt(base)) if err := os.MkdirAll(histDir, 0o755); err != nil { return WriteRecordResult{}, nil, fmt.Errorf("mkdir history: %w", err) } @@ -609,7 +613,7 @@ type HistoryEntry struct { func ListHistory(abs string) ([]HistoryEntry, error) { dir := filepath.Dir(abs) base := filepath.Base(abs) - histDir := filepath.Join(dir, historyDirName, stripExt(base)) + histDir := filepath.Join(dir, historyDirRel, stripExt(base)) ents, err := os.ReadDir(histDir) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -633,7 +637,7 @@ func ListHistory(abs string) ([]HistoryEntry, error) { } ts := stem[:idx] sha := stem[idx+1:] - entry := HistoryEntry{Ts: ts, Sha8: sha, Path: filepath.Join(historyDirName, stripExt(base), name)} + entry := HistoryEntry{Ts: ts, Sha8: sha, Path: filepath.Join(historyDirRel, stripExt(base), name)} // Pull author + revision from the archived body. if data, err := os.ReadFile(filepath.Join(histDir, name)); err == nil { snap := parsePriorAudit(data) @@ -806,209 +810,240 @@ func atoiSafe(s string) int { // ─── Markdown / text edit-history ──────────────────────────────────────── // // History-enabled text files (a `history: true` .zddc subtree — see -// zddc.PolicyChain.EffectiveHistory) keep every saved version under -// /.history//. Unlike records, text files can't carry audit -// fields in-body, so authorship + ordering live in a sidecar log: +// zddc.PolicyChain.EffectiveHistory) drop one self-describing snapshot per +// save under /.history//: // -// .history//.md one immutable blob per distinct content -// .history//log.jsonl one MdHistoryEntry per save, in order +// .history//-. one file per save // -// 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. +// The filename IS the audit record: is a colon-free UTC timestamp +// (valid on SMB / Azure Files) and is the authenticated principal. +// No sidecar log, no content hashing — listing the directory is the +// history. Reverting copies an old snapshot's bytes onto the live file +// (which then records as a fresh save). Timestamp + email are stamped +// server-side, never client-supplied. Snapshots are kept forever: a doc +// rename renames its / folder (see the move handler); a delete or a +// move out of the working dir leaves the history behind. -const mdHistoryLogName = "log.jsonl" +// mdStampLayout formats the save time WITHOUT colons or hyphens so the +// name stays valid on SMB/Windows shares AND splits cleanly on the first +// '-' into -. A literal "Z" is appended separately (a bare "Z" +// is not a Go time token). The fixed width sorts lexically by time. +const mdStampLayout = "20060102T150405.000" // 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 + Ts string `json:"ts"` // RFC3339 UTC of the save (parsed from the filename) + By string `json:"by"` // authoring principal email ("unknown" if pre-history) + ID string `json:"id"` // the snapshot filename — opaque version id for ?history= + Bytes int64 `json:"bytes"` // size of this version + Current bool `json:"current,omitempty"` // derived by ListMdHistory: the version matching the live file } -// 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 eligible for text edit- +// history at its location: its basename matches the effective history globs +// from the .zddc cascade (default "*.md", widen per-deployment via the +// `history_globs:` key). fsRoot is the server root for cascade resolution. +func IsTextHistoryCandidate(fsRoot, abs string) bool { + return matchHistoryGlobs(zddc.HistoryGlobsAt(fsRoot, filepath.Dir(abs)), filepath.Base(abs)) +} -// 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") +// matchHistoryGlobs reports whether base matches any of the globs +// (case-insensitively, so .MD matches *.md). +func matchHistoryGlobs(globs []string, base string) bool { + lb := strings.ToLower(base) + for _, g := range globs { + if ok, _ := filepath.Match(strings.ToLower(g), lb); ok { + return true + } + } + return false } func mdHistoryDir(abs string) string { - return filepath.Join(filepath.Dir(abs), historyDirName, stripExt(filepath.Base(abs))) + return filepath.Join(filepath.Dir(abs), historyDirRel, stripExt(filepath.Base(abs))) } -// WriteTextWithHistory snapshots prior and new content into -// .history//, 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. +// mdStamp renders t as the colon-free snapshot timestamp with the trailing +// Z, e.g. 20260602T143000.123Z. +func mdStamp(t time.Time) string { + return t.UTC().Format(mdStampLayout) + "Z" +} + +// sanitizeForFilename keeps a principal email filesystem-safe. Emails are +// already SMB-safe (@ . - _ +); only a path separator or control char would +// be a problem, so collapse anything outside that set to '_'. Empty → the +// "unknown" sentinel used for pre-history seed snapshots. +func sanitizeForFilename(s string) string { + if s == "" { + return "unknown" + } + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '@', r == '.', r == '_', r == '+', r == '-': + b.WriteRune(r) + default: + b.WriteRune('_') + } + } + return b.String() +} + +// parseHistoryName splits a snapshot filename "-." into a +// display timestamp (RFC3339) and the author email. ok is false for names +// that don't match the scheme (so foreign files in the dir are ignored). +// The ts field has no '-', so the first '-' is the ts/email boundary; the +// email may itself contain '-' (e.g. a hyphenated domain). +func parseHistoryName(name string) (tsRFC3339, email string, ok bool) { + ext := filepath.Ext(name) + stem := strings.TrimSuffix(name, ext) + dash := strings.IndexByte(stem, '-') + if dash <= 0 || dash == len(stem)-1 { + return "", "", false + } + tsRaw := strings.TrimSuffix(stem[:dash], "Z") + t, err := time.Parse(mdStampLayout, tsRaw) + if err != nil { + return "", "", false + } + return t.UTC().Format(time.RFC3339), stem[dash+1:], true +} + +// WriteTextWithHistory drops a snapshot of the new content into +// .history//-., then writes the live file. A save +// byte-identical to the live file is a no-op (no snapshot, no rewrite). A +// file that pre-existed history enablement is lazy-seeded: its current +// bytes are captured as an origin snapshot (stamped with the file's mtime, +// author "unknown") before the new one. The snapshot is written BEFORE the +// live file so a crash can't lose a version the live write 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 := "" + var priorMtime time.Time 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) + priorMtime = info.ModTime().UTC() } } else if err != nil && !errors.Is(err, os.ErrNotExist) { return err } - newSha := fileETag(body) - if principalEmail == "" { - principalEmail = "anonymous" + // No-op: identical to the live file → nothing to record or rewrite. + if prior != nil && bytes.Equal(prior, body) { + return nil } - 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) + // Lazy-seed the pre-history origin version (no snapshots yet but the + // file already has content). + if prior != nil && historyDirEmpty(histDir) { + seedAt := priorMtime + if seedAt.IsZero() { + seedAt = time.Now().UTC() } - 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 { + if err := writeHistorySnapshot(histDir, seedAt, "", ext, prior); 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 - } + if err := writeHistorySnapshot(histDir, time.Now().UTC(), principalEmail, ext, body); err != nil { + return err } return zddc.WriteAtomic(abs, body) } -func readMdLog(path string) ([]MdHistoryEntry, error) { - data, err := os.ReadFile(path) +// writeHistorySnapshot writes data to /-. On a +// name collision (same millisecond + author) it steps the timestamp +// forward until a free name is found, so each save keeps a distinct file. +func writeHistorySnapshot(histDir string, t time.Time, email, ext string, data []byte) error { + who := sanitizeForFilename(email) + for i := 0; i < 1000; i++ { + p := filepath.Join(histDir, mdStamp(t)+"-"+who+ext) + if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) { + return zddc.WriteAtomic(p, data) + } else if err != nil { + return err + } + t = t.Add(time.Millisecond) + } + return fmt.Errorf("history: no free snapshot name in %s", histDir) +} + +// historyDirEmpty reports whether histDir holds no snapshot files (missing +// dir counts as empty). +func historyDirEmpty(histDir string) bool { + entries, err := os.ReadDir(histDir) + if err != nil { + return true + } + for _, e := range entries { + if !e.IsDir() { + return false + } + } + return true +} + +// ListMdHistory returns the saved versions of abs, newest first, with +// Current set on the version whose bytes match the live file. +func ListMdHistory(abs string) ([]MdHistoryEntry, error) { + histDir := mdHistoryDir(abs) + dirEntries, err := os.ReadDir(histDir) if err != nil { if errors.Is(err, os.ErrNotExist) { - return nil, nil + return []MdHistoryEntry{}, nil } return nil, err } - var out []MdHistoryEntry - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if line == "" { + out := []MdHistoryEntry{} + for _, de := range dirEntries { + if de.IsDir() { continue } - var e MdHistoryEntry - if err := json.Unmarshal([]byte(line), &e); err != nil { - continue // skip a malformed line rather than fail the whole read + ts, by, ok := parseHistoryName(de.Name()) + if !ok { + continue + } + var size int64 + if info, ierr := de.Info(); ierr == nil { + size = info.Size() + } + out = append(out, MdHistoryEntry{Ts: ts, By: by, ID: de.Name(), Bytes: size}) + } + // Newest first — the id (filename) sorts lexically by its ts prefix. + sort.SliceStable(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + // Mark the newest snapshot whose bytes match the live file as current. + // Size-gate before reading so we don't slurp every version. + if live, lerr := os.ReadFile(abs); lerr == nil { + for i := range out { + if out[i].Bytes != int64(len(live)) { + continue + } + if data, rerr := os.ReadFile(filepath.Join(histDir, out[i].ID)); rerr == nil && bytes.Equal(data, live) { + out[i].Current = true + break + } } - 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 ?history=... for history-enabled -// text files: `?history=1` (or empty / `list`) returns the version list -// as JSON; `?history=` 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) { +// ServeTextHistory dispatches GET ?history=... for history-eligible +// text files: `?history=1` (or empty / `list`) returns the version list as +// JSON; `?history=` returns that snapshot's raw bytes. ACL on the live +// file has already been checked by the caller; fsRoot resolves the cascade +// for the file-type (history_globs) check. +func ServeTextHistory(w http.ResponseWriter, r *http.Request, fsRoot, abs, version string) { + if !IsTextHistoryCandidate(fsRoot, abs) { http.NotFound(w, r) return } @@ -1023,12 +1058,19 @@ func ServeTextHistory(w http.ResponseWriter, r *http.Request, abs, version strin _ = json.NewEncoder(w).Encode(entries) return } - if !mdVersionIDRe.MatchString(version) { + // version is a snapshot filename. Reject anything that could escape the + // history dir, then require it to resolve to a file strictly inside it. + if version == "." || version == ".." || strings.ContainsAny(version, "/\\") || strings.Contains(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) + histDir := mdHistoryDir(abs) + snap := filepath.Join(histDir, version) + if snap != filepath.Clean(snap) || !strings.HasPrefix(snap, histDir+string(filepath.Separator)) { + http.Error(w, "Bad Request — invalid version id", http.StatusBadRequest) + return + } + data, err := os.ReadFile(snap) if err != nil { http.NotFound(w, r) return diff --git a/zddc/internal/handler/history_test.go b/zddc/internal/handler/history_test.go index 673b10b..dde9313 100644 --- a/zddc/internal/handler/history_test.go +++ b/zddc/internal/handler/history_test.go @@ -103,7 +103,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) { } // No history dir yet (create only). - histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history") + histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history") if _, err := os.Stat(histDir); !os.IsNotExist(err) { t.Errorf(".history/ should not exist after create-only; got err=%v", err) } @@ -149,7 +149,7 @@ func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) { } // .history/ACM-PRJ-EL-SPC-0001/ has exactly one entry (the v1 bytes). - histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history", "ACM-PRJ-EL-SPC-0001") + histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history", "ACM-PRJ-EL-SPC-0001") ents, err := os.ReadDir(histDir) if err != nil { t.Fatalf("read history dir: %v", err) @@ -186,7 +186,7 @@ func TestRecordPut_ConflictPreservesHistory(t *testing.T) { t.Fatalf("expected 412, got %d body=%s", rec.Code, rec.Body.String()) } - histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history") + histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history") if _, err := os.Stat(histDir); !os.IsNotExist(err) { t.Errorf("history dir should not exist after 412 conflict; got err=%v", err) } @@ -365,11 +365,11 @@ func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) { } // History at archive/0330C1/.history/ssr/, NOT at archive/.history/. - wanted := filepath.Join(cfg.Root, "Project", "archive", "0330C1", ".history", "ssr") + wanted := filepath.Join(cfg.Root, "Project", "archive", "0330C1", ".zddc.d", "history", "ssr") if _, err := os.Stat(wanted); err != nil { t.Fatalf("expected history at %s; err=%v", wanted, err) } - bad := filepath.Join(cfg.Root, "Project", "archive", ".history") + bad := filepath.Join(cfg.Root, "Project", "archive", ".zddc.d", "history") if _, err := os.Stat(bad); !os.IsNotExist(err) { t.Errorf("history must NOT live at %s; err=%v", bad, err) } diff --git a/zddc/internal/handler/mdhistory_test.go b/zddc/internal/handler/mdhistory_test.go index d5a91b6..a40f963 100644 --- a/zddc/internal/handler/mdhistory_test.go +++ b/zddc/internal/handler/mdhistory_test.go @@ -21,7 +21,8 @@ func mustNoErr(t *testing.T, err error) { } } -func countBlobs(t *testing.T, histDir string) int { +// countSnapshots counts the per-save history files in histDir. +func countSnapshots(t *testing.T, histDir string) int { t.Helper() ents, err := os.ReadDir(histDir) if err != nil { @@ -39,11 +40,9 @@ func countBlobs(t *testing.T, histDir string) int { 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")) + histDir := filepath.Join(dir, ".zddc.d", "history", "notes") - // ── create ── + // ── create: one snapshot, authored, current ── mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "alice@x.com")) if b, _ := os.ReadFile(abs); string(b) != "v1" { t.Fatalf("live = %q, want v1", b) @@ -56,18 +55,18 @@ func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) { 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) + if entries[0].ID == "" || !strings.HasSuffix(entries[0].ID, "-alice@x.com.md") { + t.Errorf("id = %q, want a -alice@x.com.md snapshot name", entries[0].ID) + } + if _, err := os.Stat(filepath.Join(histDir, entries[0].ID)); err != nil { + t.Errorf("v1 snapshot missing: %v", err) } - // ── update ── - time.Sleep(2 * time.Millisecond) // distinct RFC3339Nano ts for ordering + // ── update: second snapshot, newest-first, current moves ── + time.Sleep(2 * time.Millisecond) // distinct timestamp 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) @@ -76,28 +75,20 @@ func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) { 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].By != "bob@x.com" || !entries[0].Current { + t.Errorf("head = %+v, want v2 by bob, 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) + if entries[1].By != "alice@x.com" || entries[1].Current { + t.Errorf("tail = %+v, want v1 by alice, non-current", entries[1]) } - // ── no-op save (identical content) → dedup, no new entry ── + // ── no-op save (identical to live) → no new snapshot ── 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)) + if n := countSnapshots(t, histDir); n != 2 { + t.Fatalf("dedup failed: want 2 snapshots, got %d", n) } - // ── restore v1 content → new log entry, blob reused ── + // ── restore v1 content → a NEW snapshot (every save is its own file) ── time.Sleep(2 * time.Millisecond) mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "carol@x.com")) if b, _ := os.ReadFile(abs); string(b) != "v1" { @@ -107,17 +98,16 @@ func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) { 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" { + if entries[0].By != "carol@x.com" || !entries[0].Current { 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. + // Only the newest matching-content entry is current, even though the + // oldest snapshot has the same bytes. 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) + if n := countSnapshots(t, histDir); n != 3 { + t.Errorf("snapshots = %d, want 3 (one file per save)", n) } } @@ -126,6 +116,7 @@ func TestWriteTextWithHistory_LazySeedPreexisting(t *testing.T) { abs := filepath.Join(dir, "doc.md") // Simulate a file that existed before history was enabled. mustNoErr(t, zddc.WriteAtomic(abs, []byte("legacy"))) + time.Sleep(2 * time.Millisecond) // keep the seed's mtime stamp < the edit mustNoErr(t, WriteTextWithHistory(abs, []byte("edited"), "dave@x.com")) @@ -135,21 +126,21 @@ func TestWriteTextWithHistory_LazySeedPreexisting(t *testing.T) { 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")) { + if entries[0].By != "dave@x.com" { 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]) + if entries[1].By != "unknown" { + t.Errorf("seed = %+v, want legacy with 'unknown' author", entries[1]) } } -func TestWriteTextWithHistory_EmptyAuthorAnonymous(t *testing.T) { +func TestWriteTextWithHistory_EmptyAuthorUnknown(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) + if len(entries) != 1 || entries[0].By != "unknown" { + t.Fatalf("empty author should record 'unknown', got %+v", entries) } } @@ -159,12 +150,11 @@ func TestServeTextHistory_ListAndVersion(t *testing.T) { 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") + ServeTextHistory(rec, req, dir, abs, "1") if rec.Code != http.StatusOK { t.Fatalf("list status = %d", rec.Code) } @@ -176,10 +166,11 @@ func TestServeTextHistory_ListAndVersion(t *testing.T) { t.Fatalf("list = %+v, want 2 newest-first", got) } - // ── specific version content ── - req = httptest.NewRequest(http.MethodGet, "/page.md?history="+sha1, nil) + // ── specific version content (oldest = "one") ── + oldID := got[1].ID + req = httptest.NewRequest(http.MethodGet, "/page.md?history="+url.QueryEscape(oldID), nil) rec = httptest.NewRecorder() - ServeTextHistory(rec, req, abs, sha1) + ServeTextHistory(rec, req, dir, abs, oldID) if rec.Code != http.StatusOK { t.Fatalf("version status = %d", rec.Code) } @@ -198,10 +189,10 @@ func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) { // 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"} { + for _, bad := range []string{"../secret", "..%2Fsecret", "abc/def", "ZZZ", "nope.md"} { req := httptest.NewRequest(http.MethodGet, "/p.md?history="+url.QueryEscape(bad), nil) rec := httptest.NewRecorder() - ServeTextHistory(rec, req, abs, bad) + ServeTextHistory(rec, req, dir, abs, bad) if rec.Code == http.StatusOK { t.Errorf("version %q unexpectedly served: body=%q", bad, rec.Body.String()) } @@ -210,12 +201,52 @@ func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) { } } - // Non-markdown path → 404 (history not applicable). + // Non-markdown path → 404 (text 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") + ServeTextHistory(rec, req, dir, yamlAbs, "1") if rec.Code != http.StatusNotFound { t.Errorf("non-md status = %d, want 404", rec.Code) } } + +// TestWriteTextWithHistory_RenameDirOnMove is covered at the handler level +// (serveFileMove); here we only assert the snapshot filenames are SMB-safe +// (no colons) so they're valid on the Azure Files share. +func TestWriteTextWithHistory_SnapshotNamesAreSMBSafe(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(dir, "n.md") + mustNoErr(t, WriteTextWithHistory(abs, []byte("a"), "cwitt@burnsmcd.com")) + entries, _ := ListMdHistory(abs) + if len(entries) != 1 { + t.Fatalf("want 1 entry, got %d", len(entries)) + } + if strings.ContainsAny(entries[0].ID, ":\\/") { + t.Errorf("snapshot id %q contains a char invalid on SMB", entries[0].ID) + } +} + +// TestIsTextHistoryCandidate_CascadeGlobs — which file types qualify for +// text history is cascade-driven (history_globs), defaulting to *.md. +func TestIsTextHistoryCandidate_CascadeGlobs(t *testing.T) { + dir := t.TempDir() + // Default (no .zddc): *.md only. + if !IsTextHistoryCandidate(dir, filepath.Join(dir, "a.md")) { + t.Errorf("default: .md should be a candidate") + } + if IsTextHistoryCandidate(dir, filepath.Join(dir, "a.txt")) { + t.Errorf("default: .txt should NOT be a candidate") + } + // Override via .zddc history_globs in a subtree. + sub := filepath.Join(dir, "notes") + mustNoErr(t, os.MkdirAll(sub, 0o755)) + mustNoErr(t, zddc.WriteAtomic(filepath.Join(sub, ".zddc"), []byte("history_globs: [\"*.txt\", \"*.md\"]\n"))) + zddc.InvalidateCache(dir) + if !IsTextHistoryCandidate(dir, filepath.Join(sub, "a.txt")) { + t.Errorf("override: .txt should be a candidate under history_globs") + } + if !IsTextHistoryCandidate(dir, filepath.Join(sub, "a.md")) { + t.Errorf("override: .md still a candidate") + } +} diff --git a/zddc/internal/handler/paths.go b/zddc/internal/handler/paths.go index 8914635..4a69265 100644 --- a/zddc/internal/handler/paths.go +++ b/zddc/internal/handler/paths.go @@ -13,9 +13,9 @@ import ( // resolvePath translates a URL `path=` query (relative to fsRoot, with // '/' separator and leading '/') into an absolute filesystem path. It // rejects path traversal and any segment beginning with '.' or '_' so -// reserved namespaces (e.g. .devshell) cannot be addressed through -// admin APIs. Returns the cleaned absolute path or an error suitable -// for a 404. +// reserved namespaces (e.g. the .zddc.d/ bookkeeping sidecar) cannot be +// addressed through admin APIs. Returns the cleaned absolute path or an +// error suitable for a 404. func resolvePath(fsRoot, urlPath string) (string, error) { urlPath = strings.TrimSpace(urlPath) if urlPath == "" { diff --git a/zddc/internal/listing/listing_test.go b/zddc/internal/listing/listing_test.go index a5819ef..2970d4b 100644 --- a/zddc/internal/listing/listing_test.go +++ b/zddc/internal/listing/listing_test.go @@ -18,7 +18,7 @@ func TestFromDirEntriesFiltersHidden(t *testing.T) { "Project-A", "Project-B", ".zddc", // hidden file - ".devshell", // hidden dir + ".zddc.d", // hidden dir (reserved bookkeeping) "_template", // scaffolding dir "_archive", // scaffolding dir "_notes.txt", // scaffolding file diff --git a/zddc/internal/zddc/cascade.go b/zddc/internal/zddc/cascade.go index 9d4cf90..7332859 100644 --- a/zddc/internal/zddc/cascade.go +++ b/zddc/internal/zddc/cascade.go @@ -66,6 +66,23 @@ func (chain PolicyChain) EffectiveHistory() bool { return false } +// EffectiveHistoryGlobs returns the basename globs selecting which files +// get text edit-history (deepest non-empty wins, then embedded defaults, +// then the built-in default ["*.md"]). Independent of EffectiveHistory: +// this says WHICH file types qualify; the bool gates whether snapshots are +// actually recorded. +func (chain PolicyChain) EffectiveHistoryGlobs() []string { + for i := len(chain.Levels) - 1; i >= 0; i-- { + if g := chain.Levels[i].HistoryGlobs; len(g) > 0 { + return g + } + } + if g := chain.Embedded.HistoryGlobs; len(g) > 0 { + return g + } + return []string{"*.md"} +} + // policyCache caches effective policies keyed by dirPath. // Values are PolicyChain. var policyCache sync.Map @@ -390,6 +407,7 @@ func nonZeroZddcFields(zf ZddcFile) []string { add("virtual", zf.Virtual != nil) add("drop_target", zf.DropTarget != nil) add("history", zf.History != nil) + add("history_globs", len(zf.HistoryGlobs) > 0) add("worm", zf.Worm != nil) add("available_tools", len(zf.AvailableTools) > 0) add("received_path", zf.ReceivedPath != "") diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index cb1b499..42a2389 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -199,10 +199,15 @@ paths: default_tool: tables available_tools: [tables] virtual: true + # Edit-history default-on for the deliverables list (subtree- + # inheriting; see working/ note). Operators override per .zddc. + history: true rsk: default_tool: tables available_tools: [tables] virtual: true + # Edit-history default-on for the risk register. + history: true working: default_tool: browse available_tools: [browse] @@ -213,6 +218,12 @@ paths: # folder" picker) and lands it at archive//working/, # which carries its own history: true + auto-own convention. virtual: true + # Edit-history default-on across the working subtree (markdown + # saves are snapshotted to .history//). Subtree-inheriting, + # so it also covers any pre-reshape /working/<…> homes + # that still hold content. Reads of recorded history never require + # this flag — turning it off only stops new snapshots. + history: true staging: default_tool: browse available_tools: [browse] @@ -309,6 +320,10 @@ paths: # tables tool serves it from the embedded default # spec even when the on-disk folder doesn't exist. virtual: true + # Edit-history default-on (markdown notes/specs saved here + # are snapshotted; .yaml records keep their own record- + # history path regardless). + history: true # MDL records: each .yaml file is an independent # deliverable with its own composed tracking number. # No type lock — the row's body fields drive the @@ -348,6 +363,8 @@ paths: # as mdl/. Embedded default-rsk spec backs it when no # operator override is on disk. virtual: true + # Edit-history default-on (same as mdl/). + history: true # RSK records: each .yaml file is a row of a parent # rsk-type deliverable. The table itself has a tracking # number (same default components as an MDL deliverable diff --git a/zddc/internal/zddc/ensure.go b/zddc/internal/zddc/ensure.go index 884abef..7c1fd20 100644 --- a/zddc/internal/zddc/ensure.go +++ b/zddc/internal/zddc/ensure.go @@ -59,9 +59,7 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) { } if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") { seg := strings.ToLower(parts[3]) - switch seg { - case "mdl", "rsk", "incoming", "received", "issued", - "working", "staging", "reviewing": + if IsPerPartySlot(seg) { if err := resolveAt(3, seg); err != nil { return target, err } @@ -117,11 +115,8 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil // caller writing under them bypassed the virtual resolver; the // content belongs under archive/// (browse's "New // folder" picker prompts for the party). - if len(parts) >= 2 { - switch strings.ToLower(parts[1]) { - 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]) - } + if len(parts) >= 2 && IsVirtualAggregatorSlot(strings.ToLower(parts[1])) { + return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1]) } resolvedSegs := make([]string, len(parts)) @@ -180,9 +175,7 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil // "archive". if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") { seg := strings.ToLower(parts[3]) - switch seg { - case "mdl", "rsk", "incoming", "received", "issued", - "working", "staging", "reviewing": + if IsPerPartySlot(seg) { if err := resolveAt(3, seg); err != nil { return target, err } diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index ad6d82b..53e2e1e 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -281,6 +281,13 @@ type ZddcFile struct { // by PolicyChain.EffectiveHistory. Empty (nil) inherits via cascade. History *bool `yaml:"history,omitempty" json:"history,omitempty"` + // HistoryGlobs selects WHICH files get text edit-history by basename + // glob (e.g. ["*.md", "*.txt"]). The History flag gates whether + // snapshots are recorded; this says which file types qualify. + // Subtree behavior, deepest non-empty wins (PolicyChain. + // EffectiveHistoryGlobs); defaults to ["*.md"] when unset. + HistoryGlobs []string `yaml:"history_globs,omitempty" json:"history_globs,omitempty"` + // Worm marks this directory (and its descendants) as // 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 diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index bca0398..9a20e19 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -159,6 +159,16 @@ func HistoryAt(fsRoot, dirPath string) bool { return chain.EffectiveHistory() } +// HistoryGlobsAt returns the effective history file-type globs at dirPath +// (default ["*.md"]). See PolicyChain.EffectiveHistoryGlobs. +func HistoryGlobsAt(fsRoot, dirPath string) []string { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return []string{"*.md"} + } + return chain.EffectiveHistoryGlobs() +} + // 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). @@ -284,12 +294,8 @@ func CanonicalFolderAt(fsRoot, dirPath string) string { return "" } // /archive// - if len(segs) == 4 && segs[1] == "archive" { - switch segs[3] { - case "incoming", "received", "issued", "mdl", "rsk", - "working", "staging", "reviewing": - return segs[3] - } + if len(segs) == 4 && segs[1] == "archive" && IsPerPartySlot(segs[3]) { + return segs[3] } return "" } diff --git a/zddc/internal/zddc/lookups_test.go b/zddc/internal/zddc/lookups_test.go index a32f85f..992433b 100644 --- a/zddc/internal/zddc/lookups_test.go +++ b/zddc/internal/zddc/lookups_test.go @@ -61,17 +61,22 @@ func TestHistoryAt_Defaults(t *testing.T) { path string want bool }{ - // 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). + // Edit-history defaults on for the three live-editing slots: + // working, mdl, rsk — at both the project-level virtual nodes and + // the per-party folders (subtree-inheriting). + {filepath.Join(root, "Project-X", "working"), true}, + {filepath.Join(root, "Project-X", "working", "alice@example.com"), true}, + {filepath.Join(root, "Project-X", "mdl"), true}, + {filepath.Join(root, "Project-X", "rsk"), true}, {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", "archive", "Acme", "mdl"), true}, + {filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), true}, + // Other 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", "ssr"), 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}, diff --git a/zddc/internal/zddc/scan_test.go b/zddc/internal/zddc/scan_test.go index d61fbfc..fc54fc1 100644 --- a/zddc/internal/zddc/scan_test.go +++ b/zddc/internal/zddc/scan_test.go @@ -25,7 +25,7 @@ func scanFixture(t *testing.T) string { mk("ProjectA/Sub/.zddc", "title: A-sub\n") mk("ProjectB/.zddc", "title: B\n") // Reserved-prefix subtrees must be pruned. - mk(".devshell/.zddc", "title: hidden\n") + mk(".zddc.d/.zddc", "title: hidden\n") mk("_template/.zddc", "title: scaffold\n") return root } diff --git a/zddc/internal/zddc/slots.go b/zddc/internal/zddc/slots.go new file mode 100644 index 0000000..89cadf0 --- /dev/null +++ b/zddc/internal/zddc/slots.go @@ -0,0 +1,61 @@ +package zddc + +import "strings" + +// Canonical project slots — the fixed lifecycle shape of a project. +// +// The binary wires bespoke behavior to each of these names (transmittal at +// staging/, plan-review at received/, tables rollups at mdl/rsk, folder-nav +// at working/staging/reviewing), so the SET of slot names is a deliberate +// hard rule rather than a cascade key. The point of this file is that the +// set lives in ONE place: handlers ask the predicates below instead of +// re-listing the names, so adding or adjusting a slot is a single edit, not +// a hunt across ensure.go / fileapi.go / virtualviews.go / lookups.go. +// +// Note the layering: the slot NAMES are hard-coded here, but per-slot +// BEHAVIOR (default_tool, history, worm, auto_own, virtual, …) stays +// cascade-driven in defaults.zddc.yaml + on-disk .zddc. This file is +// identity/shape only. +var ( + // rowSlots: project-level tables rollups (ssr) + the per-party record + // folders they aggregate (mdl, rsk). + rowSlots = []string{"ssr", "mdl", "rsk"} + // folderNavSlots: project-level folder-nav aggregators. + folderNavSlots = []string{"working", "staging", "reviewing"} + // perPartySlots: the physical lifecycle folders under archive//. + // (ssr is a file — ssr.yaml — not a folder, so it's not here.) + perPartySlots = []string{"incoming", "received", "issued", "mdl", "rsk", "working", "staging", "reviewing"} +) + +func slotIn(set []string, s string) bool { + for _, v := range set { + if v == s { + return true + } + } + return false +} + +// IsRowSlot reports whether slot is a tables-rollup slot (ssr/mdl/rsk). +func IsRowSlot(slot string) bool { return slotIn(rowSlots, slot) } + +// IsFolderNavSlot reports whether slot is a folder-nav lifecycle slot +// (working/staging/reviewing). +func IsFolderNavSlot(slot string) bool { return slotIn(folderNavSlots, slot) } + +// IsVirtualAggregatorSlot reports whether slot is one of the six +// project-level virtual aggregators (row rollups + folder-nav). These have +// no physical presence at the project root; content is party-scoped. +func IsVirtualAggregatorSlot(slot string) bool { + return IsRowSlot(slot) || IsFolderNavSlot(slot) +} + +// IsPerPartySlot reports whether slot is a physical per-party lifecycle +// folder under archive//. +func IsPerPartySlot(slot string) bool { return slotIn(perPartySlots, slot) } + +// virtualAggregatorAlternation returns the six aggregator slot names as a +// regex alternation (rowSlots then folderNavSlots) for virtualViewRE. +func virtualAggregatorAlternation() string { + return strings.Join(append(append([]string{}, rowSlots...), folderNavSlots...), "|") +} diff --git a/zddc/internal/zddc/virtualviews.go b/zddc/internal/zddc/virtualviews.go index b60a8f3..9d507e2 100644 --- a/zddc/internal/zddc/virtualviews.go +++ b/zddc/internal/zddc/virtualviews.go @@ -162,7 +162,7 @@ type VirtualViewResolution struct { // virtualViewRE matches //[/] where slot is one // of the canonical virtual view names. Capture 1 = project, capture // 2 = slot, capture 3 = rest (may be empty). -var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(ssr|mdl|rsk|working|staging|reviewing)(?:/(.*))?$`) +var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(` + virtualAggregatorAlternation() + `)(?:/(.*))?$`) // partyNameRE matches the SSR schema's `name` pattern. Same regex // used at row-resolution time so URLs with invalid party tokens fail @@ -176,15 +176,8 @@ func ValidPartyName(s string) bool { return partyNameRE.MatchString(s) } -// IsFolderNavSlot reports whether slot is one of the folder-nav -// lifecycle slots (working, staging, reviewing). -func IsFolderNavSlot(slot string) bool { - switch slot { - case "working", "staging", "reviewing": - return true - } - return false -} +// IsFolderNavSlot / IsRowSlot / IsVirtualAggregatorSlot / IsPerPartySlot +// live in slots.go (the single canonical-slot registry). // planReviewURLRE matches //archive//received// // — the only URL shape Plan Review accepts. Trailing slash optional. @@ -203,16 +196,6 @@ func IsPlanReviewURL(urlPath string) bool { return planReviewURLRE.MatchString(urlPath) } -// IsRowSlot reports whether slot is one of the tables-rollup slots -// (ssr, mdl, rsk). -func IsRowSlot(slot string) bool { - switch slot { - case "ssr", "mdl", "rsk": - return true - } - return false -} - // ResolveVirtualView inspects urlPath and returns a populated // resolution iff the URL targets one of the project-level virtual // views (ssr/, mdl/, rsk/, working/, staging/, reviewing/). diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go index a0328b3..ac4a953 100644 --- a/zddc/internal/zddc/walker.go +++ b/zddc/internal/zddc/walker.go @@ -91,6 +91,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile { if top.History != nil { out.History = top.History } + if len(top.HistoryGlobs) > 0 { + out.HistoryGlobs = top.HistoryGlobs + } // Worm: presence (non-nil, even empty) marks the WORM zone. // Concat-dedupe across levels (a deeper .zddc adds controllers); // preserve a non-nil empty slice so `worm: []` survives the