From 760cba96c4bb999c8e1288f8f3a27a4e261822c2 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 4 Jun 2026 09:53:53 -0500 Subject: [PATCH] feat(server): add declarative `views:` cascade key + ViewAt resolver (schema) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the generalized view model: `.zddc` declares, per URL shape, which tool renders and where its supporting config lives. - ZddcFile.Views map[string]ViewSpec{Tool, Config}; shapes "dir" / "dir_slash" / "file". config is a filename resolved under /.zddc.d/. Pure data — no behaviour; presentation/routing only (ACL/WORM/admin stay server-enforced). - lookups.ViewAt(root, dir, shape): cascade leaf→root first-match, with default_tool / dir_tool honored as sugar for dir / dir_slash (semantics unchanged). No merged map — resolved per-shape like DefaultToolAt. - cascade summary, isZero/is-empty checks, and validation (tool ∈ AppNames; config a path-bounded plain filename). Client .zddc validator (preview-yaml.js) gains a `views` key + `viewmap` case. Additive only — nothing consumes Views yet (the generic resolver + dispatch wiring + recognizer retirement follow). go build + zddc/handler tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/js/preview-yaml.js | 27 +++++++++++++++++++++ zddc/internal/handler/zddcfile.go | 1 + zddc/internal/zddc/cascade.go | 1 + zddc/internal/zddc/file.go | 24 +++++++++++++++++++ zddc/internal/zddc/lookups.go | 40 ++++++++++++++++++++++++++++++- zddc/internal/zddc/validate.go | 19 +++++++++++++++ 6 files changed, 111 insertions(+), 1 deletion(-) diff --git a/browse/js/preview-yaml.js b/browse/js/preview-yaml.js index 95bb540..22f3903 100644 --- a/browse/js/preview-yaml.js +++ b/browse/js/preview-yaml.js @@ -107,6 +107,7 @@ paths: 'pathmap', display: 'stringmap', tables: 'stringmap', + views: 'viewmap', convert: 'convert', created_by: 'string', inherit: 'bool' @@ -223,6 +224,32 @@ walkObject(v, TOP_KEYS, path.concat([seg]), issues); } return; + case 'viewmap': + if (t === 'null') return; + if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } + for (var shape in val) { + if (!Object.prototype.hasOwnProperty.call(val, shape)) continue; + if (['dir', 'dir_slash', 'file'].indexOf(shape) === -1) { + issues.push({ keyPath: path.concat([shape]), severity: 'warning', + message: 'Unknown view shape "' + shape + '" (known: dir, dir_slash, file).' }); + } + var vv = val[shape]; + if (typeOf(vv) !== 'object') { + issues.push({ keyPath: path.concat([shape]), severity: 'error', + message: 'views.' + shape + ' must be a map ({tool, config}).' }); + continue; + } + if (typeOf(vv.tool) !== 'string' || !ALLOWED_TOOLS[vv.tool]) { + issues.push({ keyPath: path.concat([shape, 'tool']), severity: 'warning', + message: 'views.' + shape + '.tool should be a known tool (' + + Object.keys(ALLOWED_TOOLS).join(', ') + ').' }); + } + if (vv.config !== undefined && typeOf(vv.config) !== 'string') { + issues.push({ keyPath: path.concat([shape, 'config']), severity: 'error', + message: 'views.' + shape + '.config must be a filename string.' }); + } + } + return; case 'rolemap': if (t === 'null') return; if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } diff --git a/zddc/internal/handler/zddcfile.go b/zddc/internal/handler/zddcfile.go index 4f76f02..eba1009 100644 --- a/zddc/internal/handler/zddcfile.go +++ b/zddc/internal/handler/zddcfile.go @@ -296,6 +296,7 @@ func isZeroZddcFile(zf zddc.ZddcFile) bool { len(zf.ACL.Permissions) == 0 && len(zf.Admins) == 0 && len(zf.Tables) == 0 && + len(zf.Views) == 0 && len(zf.Display) == 0 && len(zf.Roles) == 0 && len(zf.FieldCodes) == 0 && diff --git a/zddc/internal/zddc/cascade.go b/zddc/internal/zddc/cascade.go index c717277..cd2bb78 100644 --- a/zddc/internal/zddc/cascade.go +++ b/zddc/internal/zddc/cascade.go @@ -394,6 +394,7 @@ func nonZeroZddcFields(zf ZddcFile) []string { add("acl", len(zf.ACL.Permissions) > 0 || zf.ACL.Inherit != nil) add("admins", len(zf.Admins) > 0) add("tables", len(zf.Tables) > 0) + add("views", len(zf.Views) > 0) add("display", len(zf.Display) > 0) add("convert", zf.Convert != nil) add("roles", len(zf.Roles) > 0) diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index 47b32c1..e990ef7 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -92,6 +92,15 @@ type ConvertMetadata struct { ProjectNumber string `yaml:"project_number,omitempty" json:"project_number,omitempty"` } +// ViewSpec is one entry in ZddcFile.Views: which tool renders a given URL +// shape, and the filename (under /.zddc.d/) of its supporting config. +// Config is optional (e.g. browse needs none). Both are plain data — no +// behaviour. See ZddcFile.Views. +type ViewSpec struct { + Tool string `yaml:"tool,omitempty" json:"tool,omitempty"` + Config string `yaml:"config,omitempty" json:"config,omitempty"` +} + // ZddcFile represents the parsed contents of a .zddc configuration file. // // Admins is honored only in the root .zddc file (/.zddc); subdir @@ -197,6 +206,21 @@ type ZddcFile struct { // Cascades leaf→root like DefaultTool. DirTool string `yaml:"dir_tool,omitempty" json:"dir_tool,omitempty"` + // Views declares, per URL shape, which tool renders and where its + // supporting config lives — the generalization of default_tool/dir_tool + // plus the form/table recognizers. Keys are URL shapes: + // "dir" — GET (no slash) e.g. {tool: tables, config: table.yaml} + // "dir_slash" — GET / e.g. {tool: browse} + // "file" — GET / (no slash) e.g. {tool: form, config: form.yaml} + // config is a filename resolved under /.zddc.d/ (the supporting-files + // reserve), server-resolved and injected (#view-context) since .zddc.d/ is + // not client-fetchable. A view is presentation/routing ONLY — it never + // grants access; ACL/WORM/admin stay server-enforced. default_tool / + // dir_tool are normalized into views.dir / views.dir_slash (kept as sugar). + // Cascades leaf→root like DefaultTool. No arbitrary code: tool ∈ the known + // app set, config is a path-bounded relative name. + Views map[string]ViewSpec `yaml:"views,omitempty" json:"views,omitempty"` + // AutoOwn controls whether the file API's mkdir post-hook writes // an auto-owned .zddc granting the creator rwcda at the new // directory. Useful for working/staging/incoming-style drafting diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index aac026f..eed0b75 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -56,6 +56,44 @@ func DirToolAt(fsRoot, dirPath string) string { return "browse" } +// ViewAt resolves the view for a URL shape ("dir", "dir_slash", "file") at +// dirPath. Walks the cascade leaf→root (then the embedded defaults): the first +// level whose Views declares the shape wins; default_tool / dir_tool are +// honored as sugar for the "dir" / "dir_slash" shapes. Returns +// (ViewSpec{}, false) when nothing declares the shape (the caller decides any +// floor, e.g. dir_slash → browse). Mirrors DefaultToolAt's first-match-wins +// cascade so default_tool/dir_tool semantics are unchanged. +func ViewAt(fsRoot, dirPath, shape string) (ViewSpec, bool) { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return ViewSpec{}, false + } + atLevel := func(lvl ZddcFile) (ViewSpec, bool) { + if lvl.Views != nil { + if v, ok := lvl.Views[shape]; ok && v.Tool != "" { + return v, true + } + } + switch shape { + case "dir": + if lvl.DefaultTool != "" { + return ViewSpec{Tool: lvl.DefaultTool}, true + } + case "dir_slash": + if lvl.DirTool != "" { + return ViewSpec{Tool: lvl.DirTool}, true + } + } + return ViewSpec{}, false + } + for i := len(chain.Levels) - 1; i >= 0; i-- { + if v, ok := atLevel(chain.Levels[i]); ok { + return v, true + } + } + return atLevel(chain.Embedded) +} + // AutoOwnAt reports whether mkdir at THIS specific directory should // write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT // propagate to descendants (creating working/alice/notes/sub/ does @@ -390,7 +428,7 @@ func isZeroZddcFile(zf ZddcFile) bool { if zf.ACL.Inherit != nil { return false } - if len(zf.Tables) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 { + if len(zf.Tables) > 0 || len(zf.Views) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 { return false } if len(zf.Roles) > 0 { diff --git a/zddc/internal/zddc/validate.go b/zddc/internal/zddc/validate.go index daeb8e4..8d9ec41 100644 --- a/zddc/internal/zddc/validate.go +++ b/zddc/internal/zddc/validate.go @@ -133,6 +133,25 @@ func ValidateFile(zf ZddcFile) []FieldError { Message: "title exceeds 200 characters", }) } + // views: each entry names a known tool and (optionally) a config file + // resolved under /.zddc.d/ — so it must be a safe relative filename + // (no slashes, no traversal, no leading dot). + for shape, v := range zf.Views { + if v.Tool == "" || !IsKnownApp(v.Tool) { + errs = append(errs, FieldError{ + Field: fmt.Sprintf("views.%s.tool", shape), + Message: fmt.Sprintf("unknown tool %q (known: %s)", v.Tool, strings.Join(AppNames, ", ")), + }) + } + if v.Config != "" { + if strings.ContainsAny(v.Config, "/\\") || v.Config == "." || v.Config == ".." || strings.HasPrefix(v.Config, ".") { + errs = append(errs, FieldError{ + Field: fmt.Sprintf("views.%s.config", shape), + Message: "config must be a plain filename (resolved under .zddc.d/); no slashes, traversal, or leading dot", + }) + } + } + } // worm: is a list of principal patterns (email-globs, @role:name, // or bare role names) that get write-once-create inside the WORM // zone. Validate each as an email-glob unless it's a role