diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 0eb7eae..a16ed71 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -1341,13 +1341,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 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, absPath, version) + handler.ServeTextHistory(w, r, cfg.Root, absPath, version) return } handler.ServeHistoryList(w, r, absPath) diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index 6390ad5..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 @@ -690,7 +690,7 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) { // 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(srcAbs) && filepath.Dir(srcAbs) == filepath.Dir(dstAbs) { + if IsTextHistoryCandidate(cfg.Root, srcAbs) && filepath.Dir(srcAbs) == filepath.Dir(dstAbs) { oldHist := mdHistoryDir(srcAbs) newHist := mdHistoryDir(dstAbs) if oldHist != newHist { @@ -860,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//..." @@ -890,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/history.go b/zddc/internal/handler/history.go index ae24c9d..586af37 100644 --- a/zddc/internal/handler/history.go +++ b/zddc/internal/handler/history.go @@ -839,11 +839,24 @@ type MdHistoryEntry struct { Current bool `json:"current,omitempty"` // derived by ListMdHistory: the version matching the live file } -// IsTextHistoryCandidate reports whether abs is a text file eligible for -// edit-history versioning. Scoped to markdown (the browse editor surface); -// widen here to add .txt etc. -func IsTextHistoryCandidate(abs string) bool { - return strings.EqualFold(filepath.Ext(abs), ".md") +// 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)) +} + +// 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 { @@ -1024,12 +1037,13 @@ func ListMdHistory(abs string) ([]MdHistoryEntry, error) { return out, 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 } diff --git a/zddc/internal/handler/mdhistory_test.go b/zddc/internal/handler/mdhistory_test.go index 31aaa42..5f58a34 100644 --- a/zddc/internal/handler/mdhistory_test.go +++ b/zddc/internal/handler/mdhistory_test.go @@ -154,7 +154,7 @@ func TestServeTextHistory_ListAndVersion(t *testing.T) { // ── 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) } @@ -170,7 +170,7 @@ func TestServeTextHistory_ListAndVersion(t *testing.T) { oldID := got[1].ID req = httptest.NewRequest(http.MethodGet, "/page.md?history="+url.QueryEscape(oldID), nil) rec = httptest.NewRecorder() - ServeTextHistory(rec, req, abs, oldID) + ServeTextHistory(rec, req, dir, abs, oldID) if rec.Code != http.StatusOK { t.Fatalf("version status = %d", rec.Code) } @@ -192,7 +192,7 @@ func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) { 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()) } @@ -205,7 +205,7 @@ func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) { 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) } @@ -226,3 +226,27 @@ func TestWriteTextWithHistory_SnapshotNamesAreSMBSafe(t *testing.T) { 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/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/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/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