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:
parent
4e86b1533d
commit
760cba96c4
6 changed files with 111 additions and 1 deletions
|
|
@ -107,6 +107,7 @@
|
||||||
paths: 'pathmap',
|
paths: 'pathmap',
|
||||||
display: 'stringmap',
|
display: 'stringmap',
|
||||||
tables: 'stringmap',
|
tables: 'stringmap',
|
||||||
|
views: 'viewmap',
|
||||||
convert: 'convert',
|
convert: 'convert',
|
||||||
created_by: 'string',
|
created_by: 'string',
|
||||||
inherit: 'bool'
|
inherit: 'bool'
|
||||||
|
|
@ -223,6 +224,32 @@
|
||||||
walkObject(v, TOP_KEYS, path.concat([seg]), issues);
|
walkObject(v, TOP_KEYS, path.concat([seg]), issues);
|
||||||
}
|
}
|
||||||
return;
|
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':
|
case 'rolemap':
|
||||||
if (t === 'null') return;
|
if (t === 'null') return;
|
||||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,7 @@ func isZeroZddcFile(zf zddc.ZddcFile) bool {
|
||||||
len(zf.ACL.Permissions) == 0 &&
|
len(zf.ACL.Permissions) == 0 &&
|
||||||
len(zf.Admins) == 0 &&
|
len(zf.Admins) == 0 &&
|
||||||
len(zf.Tables) == 0 &&
|
len(zf.Tables) == 0 &&
|
||||||
|
len(zf.Views) == 0 &&
|
||||||
len(zf.Display) == 0 &&
|
len(zf.Display) == 0 &&
|
||||||
len(zf.Roles) == 0 &&
|
len(zf.Roles) == 0 &&
|
||||||
len(zf.FieldCodes) == 0 &&
|
len(zf.FieldCodes) == 0 &&
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,7 @@ func nonZeroZddcFields(zf ZddcFile) []string {
|
||||||
add("acl", len(zf.ACL.Permissions) > 0 || zf.ACL.Inherit != nil)
|
add("acl", len(zf.ACL.Permissions) > 0 || zf.ACL.Inherit != nil)
|
||||||
add("admins", len(zf.Admins) > 0)
|
add("admins", len(zf.Admins) > 0)
|
||||||
add("tables", len(zf.Tables) > 0)
|
add("tables", len(zf.Tables) > 0)
|
||||||
|
add("views", len(zf.Views) > 0)
|
||||||
add("display", len(zf.Display) > 0)
|
add("display", len(zf.Display) > 0)
|
||||||
add("convert", zf.Convert != nil)
|
add("convert", zf.Convert != nil)
|
||||||
add("roles", len(zf.Roles) > 0)
|
add("roles", len(zf.Roles) > 0)
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,15 @@ type ConvertMetadata struct {
|
||||||
ProjectNumber string `yaml:"project_number,omitempty" json:"project_number,omitempty"`
|
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.
|
// ZddcFile represents the parsed contents of a .zddc configuration file.
|
||||||
//
|
//
|
||||||
// Admins is honored only in the root .zddc file (<ZDDC_ROOT>/.zddc); subdir
|
// 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.
|
// Cascades leaf→root like DefaultTool.
|
||||||
DirTool string `yaml:"dir_tool,omitempty" json:"dir_tool,omitempty"`
|
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
|
// AutoOwn controls whether the file API's mkdir post-hook writes
|
||||||
// an auto-owned .zddc granting the creator rwcda at the new
|
// an auto-owned .zddc granting the creator rwcda at the new
|
||||||
// directory. Useful for working/staging/incoming-style drafting
|
// directory. Useful for working/staging/incoming-style drafting
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,44 @@ func DirToolAt(fsRoot, dirPath string) string {
|
||||||
return "browse"
|
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
|
// AutoOwnAt reports whether mkdir at THIS specific directory should
|
||||||
// write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT
|
// write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT
|
||||||
// propagate to descendants (creating working/alice/notes/sub/ does
|
// propagate to descendants (creating working/alice/notes/sub/ does
|
||||||
|
|
@ -390,7 +428,7 @@ func isZeroZddcFile(zf ZddcFile) bool {
|
||||||
if zf.ACL.Inherit != nil {
|
if zf.ACL.Inherit != nil {
|
||||||
return false
|
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
|
return false
|
||||||
}
|
}
|
||||||
if len(zf.Roles) > 0 {
|
if len(zf.Roles) > 0 {
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,25 @@ func ValidateFile(zf ZddcFile) []FieldError {
|
||||||
Message: "title exceeds 200 characters",
|
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,
|
// worm: is a list of principal patterns (email-globs, @role:name,
|
||||||
// or bare role names) that get write-once-create inside the WORM
|
// or bare role names) that get write-once-create inside the WORM
|
||||||
// zone. Validate each as an email-glob unless it's a role
|
// zone. Validate each as an email-glob unless it's a role
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue