feat(server): add declarative views: cascade key + ViewAt resolver (schema)

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 <dir>/.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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-04 09:53:53 -05:00
parent 4e86b1533d
commit 760cba96c4
6 changed files with 111 additions and 1 deletions

View file

@ -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; }

View file

@ -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 &&

View file

@ -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)

View file

@ -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 <dir>/.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_ROOT>/.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 <dir> (no slash) e.g. {tool: tables, config: table.yaml}
// "dir_slash" — GET <dir>/ e.g. {tool: browse}
// "file" — GET <dir>/<file> (no slash) e.g. {tool: form, config: form.yaml}
// config is a filename resolved under <dir>/.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

View file

@ -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 {

View file

@ -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 <dir>/.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