// Package handler — tablehandler.go: directory-of-YAML table view. // // URL convention: // // GET //.table.html → tables.html, only when /.zddc // declares tables: { : } // AND the spec file exists. // // Discovery is .zddc-declarative (no auto-mount on file presence). The // handler's only jobs are: // // 1. Recognize the URL — does /.zddc declare a table named , // and does the referenced *.table.yaml spec actually exist? If not, // fall through to the static-file pipeline. // 2. Gate on the cascading ACL at the request directory (read action). // 3. Serve the static tables.html bytes. // // All rendering is client-side. The page (built from tables/) detects // HTTP vs file:// mode, then walks the directory via shared/zddc-source.js // (HttpDirectoryHandle in HTTP mode, real FileSystemDirectoryHandle in // file mode), reads .zddc, parses the spec, and renders rows in the // browser. The server does not pre-parse the spec, list rows, or compute // per-row "editable" — the client does, and ACL is enforced naturally: // HTTP-mode reads/writes go through the cascade-aware file API, and // local-mode reads/writes are bounded by whatever the OS gave the // FS-Access handle. package handler import ( _ "embed" "encoding/json" "log/slog" "net/http" "os" "path/filepath" "sort" "strings" "gopkg.in/yaml.v3" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) //go:embed tables.html var embeddedTablesHTML []byte //go:embed default-mdl.table.yaml var embeddedDefaultMdlTable []byte //go:embed default-mdl.form.yaml var embeddedDefaultMdlForm []byte //go:embed default-rsk.table.yaml var embeddedDefaultRskTable []byte //go:embed default-rsk.form.yaml var embeddedDefaultRskForm []byte //go:embed default-ssr.table.yaml var embeddedDefaultSsrTable []byte //go:embed default-ssr.form.yaml var embeddedDefaultSsrForm []byte //go:embed default-project-mdl.table.yaml var embeddedDefaultProjectMdlTable []byte //go:embed default-project-rsk.table.yaml var embeddedDefaultProjectRskTable []byte //go:embed default-project-mdl.form.yaml var embeddedDefaultProjectMdlForm []byte //go:embed default-project-rsk.form.yaml var embeddedDefaultProjectRskForm []byte // DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes. // Used by callers that need the canonical spec without going through // the URL-recognition path. func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable } // DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes. func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm } // DefaultRskTableYAML returns the embedded default rsk.table.yaml bytes. func DefaultRskTableYAML() []byte { return embeddedDefaultRskTable } // DefaultRskFormYAML returns the embedded default rsk.form.yaml bytes. func DefaultRskFormYAML() []byte { return embeddedDefaultRskForm } // DefaultSsrTableYAML returns the embedded default ssr.table.yaml bytes. func DefaultSsrTableYAML() []byte { return embeddedDefaultSsrTable } // DefaultSsrFormYAML returns the embedded default ssr.form.yaml bytes. func DefaultSsrFormYAML() []byte { return embeddedDefaultSsrForm } // DefaultProjectMdlTableYAML returns the embedded project-rollup // mdl.table.yaml bytes. func DefaultProjectMdlTableYAML() []byte { return embeddedDefaultProjectMdlTable } // DefaultProjectRskTableYAML returns the embedded project-rollup // rsk.table.yaml bytes. func DefaultProjectRskTableYAML() []byte { return embeddedDefaultProjectRskTable } // DefaultProjectMdlFormYAML returns the embedded project-rollup // mdl.form.yaml bytes. Differs from the per-party MDL form by an // additional required `party` field — the routing key for the // rollup create handler. func DefaultProjectMdlFormYAML() []byte { return embeddedDefaultProjectMdlForm } // DefaultProjectRskFormYAML returns the embedded project-rollup // rsk.form.yaml bytes. func DefaultProjectRskFormYAML() []byte { return embeddedDefaultProjectRskForm } // IsDefaultSpec reports whether urlPath is one of the embedded // default-spec virtual files served when no operator file exists on // disk. Recognized URL shapes: // // /mdl/{table.yaml, form.yaml} aggregate (with $party) // /rsk/{table.yaml, form.yaml} aggregate (with $party) // /ssr/{table.yaml, form.yaml} registry table // /mdl//{table.yaml, form.yaml} per-party // /rsk//{table.yaml, form.yaml} per-party // // Returns embedded bytes + true when the fallback should fire; nil + // false when an operator file exists at that path or the URL is not // eligible. Operator files always win. func IsDefaultSpec(fsRoot, urlPath string) ([]byte, bool) { rel := strings.TrimPrefix(filepath.ToSlash(urlPath), "/") abs := filepath.Join(fsRoot, filepath.FromSlash(rel)) if !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) && abs != fsRoot { return nil, false } return IsDefaultSpecAbs(fsRoot, abs) } // IsDefaultSpecAbs is the abs-path-keyed variant of IsDefaultSpec. // Used by handlers that hold a filesystem path rather than a URL. func IsDefaultSpecAbs(fsRoot, absPath string) ([]byte, bool) { if !strings.HasPrefix(absPath, fsRoot+string(filepath.Separator)) && absPath != fsRoot { return nil, false } rel, err := filepath.Rel(fsRoot, absPath) if err != nil { return nil, false } rel = filepath.ToSlash(rel) if rel == "" || rel == "." || strings.HasPrefix(rel, "../") { return nil, false } bytes := classifyDefaultSpec(rel) if bytes == nil { return nil, false } // Operator file wins if it exists on disk. if fileExists(absPath) { return nil, false } return bytes, true } // classifyDefaultSpec maps a slash-form path (relative to fsRoot) to // the matching embedded default-spec bytes, or nil if the path does // not name one of the recognized virtual fallback files. func classifyDefaultSpec(rel string) []byte { parts := strings.Split(rel, "/") // A spec may live either in the directory root (/table.yaml) or in // the supporting-files reserve (/.zddc.d/table.yaml). Strip a // ".zddc.d" segment so both classify by the same dir shape. clean := parts[:0:0] for _, p := range parts { if strings.EqualFold(p, ".zddc.d") { continue } clean = append(clean, p) } parts = clean switch len(parts) { case 4: // /// — per-party register specs // (mdl//, rsk//). The single-party table/form, // no $party column. peer := strings.ToLower(parts[1]) file := strings.ToLower(parts[3]) switch peer { case "mdl": switch file { case "table.yaml": return embeddedDefaultMdlTable case "form.yaml": return embeddedDefaultMdlForm } case "rsk": switch file { case "table.yaml": return embeddedDefaultRskTable case "form.yaml": return embeddedDefaultRskForm } } case 3: // // — peer-root specs. ssr/ is a flat // register; mdl/ and rsk/ are the cross-party AGGREGATE tables // (the project-level spec carries the $party column). peer := strings.ToLower(parts[1]) file := strings.ToLower(parts[2]) switch peer { case "ssr": switch file { case "table.yaml": return embeddedDefaultSsrTable case "form.yaml": return embeddedDefaultSsrForm } case "mdl": switch file { case "table.yaml": return embeddedDefaultProjectMdlTable case "form.yaml": return embeddedDefaultProjectMdlForm } case "rsk": switch file { case "table.yaml": return embeddedDefaultProjectRskTable case "form.yaml": return embeddedDefaultProjectRskForm } } } return nil } // isAtArchivePartyLevel reports whether urlPath refers to a file // directly under /archive// (depth-3 directory). The // canonical-folder names are case-folded. func isAtArchivePartyLevel(fsRoot, urlPath string) bool { rel := strings.Trim(filepath.ToSlash(urlPath), "/") parts := strings.Split(rel, "/") if len(parts) != 4 { return false } return strings.EqualFold(parts[1], "archive") } // TableRequest describes a recognized table-system request. type TableRequest struct { // Name is the table's URL stem (the key declared in .zddc tables). Name string // SpecPath is the absolute filesystem path to the *.table.yaml. // May reference a virtual path when the spec is served from // embedded defaults. SpecPath string // Dir is the absolute path to the request directory (where the // .zddc declared the table). Dir string } // tableRowsRedirect reports the canonical //table.html URL to // redirect to when (urlPath) names a directory that contains a // table.yaml (or matches one of the default-spec fallbacks). Returns // "" when no redirect should fire. // // Recognition reuses RecognizeTableRequest by synthesizing the // equivalent table.html and asking the recognizer whether // it's a real (or default-spec) table. Single source of truth for // validation. func tableRowsRedirect(fsRoot, urlPath string) string { if urlPath == "" || urlPath == "/" { return "" } if !strings.HasSuffix(urlPath, "/") { urlPath += "/" } synthesized := urlPath + "table.html" tr := RecognizeTableRequest(fsRoot, http.MethodGet, synthesized) if tr == nil { return "" } // Default-spec case (no on-disk table.yaml): follow the slash/no- // slash convention — slash form serves browse, no-slash serves // tables (handled by the dispatcher). Redirecting here would // override the convention and force the user into the table view // from any //{mdl,rsk}/ click. if !fileExists(tr.SpecPath) { return "" } return synthesized } // RecognizeTableRequest classifies r as a table-system request, or // returns nil if it falls through to other handlers. Discovery is // presence-based and self-contained: a //table.html URL fires // when /table.yaml exists on disk, or when one of the default- // spec fallbacks applies (per-party mdl/rsk under archive//, // or project-level ssr/mdl/rsk virtual aggregations). // // The table's "name" is the directory's basename for on-disk and // per-party-virtual tables (e.g. "mdl"); for project-level virtual // tables it's the slot name ("ssr", "mdl", "rsk"). // // Methods other than GET return nil — the table is read-only at the // URL level. Writes go through the file API directly. func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest { if method != http.MethodGet { return nil } if !strings.HasSuffix(urlPath, "/table.html") && urlPath != "/table.html" { return nil } rel := strings.TrimSuffix(strings.TrimPrefix(urlPath, "/"), "/table.html") rel = strings.TrimSuffix(rel, "table.html") rel = strings.Trim(rel, "/") if rel == "" { return nil } dirAbs := filepath.Join(fsRoot, filepath.FromSlash(rel)) if !strings.HasPrefix(dirAbs, fsRoot+string(filepath.Separator)) && dirAbs != fsRoot { return nil } name := filepath.Base(dirAbs) specAbs := filepath.Join(dirAbs, "table.yaml") // Presence-based discovery: the spec in the supporting-files reserve // (/.zddc.d/table.yaml) or, legacy, the directory root. if fileExists(filepath.Join(dirAbs, ".zddc.d", "table.yaml")) || fileExists(specAbs) { return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs} } // Default-spec fallbacks — the rows-dir itself may not exist on // disk yet (fully virtual). The static-file dispatcher serves the // embedded spec bytes from IsDefaultSpecAbs when the client // fetches /table.yaml client-side. if slot, ok := classifyVirtualTableDir(fsRoot, dirAbs); ok { return &TableRequest{Name: slot, SpecPath: specAbs, Dir: dirAbs} } return nil } // classifyVirtualTableDir reports whether dirAbs is one of the // virtual-spec table dirs and returns its slot name ("mdl", "rsk", // or "ssr"). Recognizes both per-party slots // (/archive//{mdl,rsk}) and project-level slots // (/{ssr,mdl,rsk}). func classifyVirtualTableDir(fsRoot, dirAbs string) (string, bool) { rel, err := filepath.Rel(fsRoot, dirAbs) if err != nil { return "", false } rel = filepath.ToSlash(rel) if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." { return "", false } parts := strings.Split(rel, "/") switch len(parts) { case 2: // / — aggregate ssr/mdl/rsk table. slot := strings.ToLower(parts[1]) if slot == "ssr" || slot == "mdl" || slot == "rsk" { return slot, true } case 3: // // — per-party mdl/rsk table. slot := strings.ToLower(parts[1]) if slot == "mdl" || slot == "rsk" { return slot, true } } return "", false } // isNotExistError reports whether err indicates a missing file. Local // helper to avoid pulling errors.Is into the handler package. func isNotExistError(err error) bool { return err != nil && strings.Contains(err.Error(), "no such file or directory") } // LoadViewSpec resolves a config file's bytes for dir, preferring the // supporting-files reserve /.zddc.d/, then the legacy /, // then the embedded default for this dir's shape. Returns nil when none // applies. This is the single seam that puts table/form specs under .zddc.d/ // (where they're admin-gated + hidden) while staying back-compatible. func LoadViewSpec(fsRoot, dir, name string) []byte { if b, err := os.ReadFile(filepath.Join(dir, ".zddc.d", name)); err == nil { return b } if b, err := os.ReadFile(filepath.Join(dir, name)); err == nil { return b } if rel, err := filepath.Rel(fsRoot, filepath.Join(dir, name)); err == nil { if b := classifyDefaultSpec(filepath.ToSlash(rel)); b != nil { return b } } return nil } // injectTableContext writes the resolved table spec + row-form schema into the // `#table-context` placeholder so the client reads them instead of fetching // /table.yaml and /form.yaml over HTTP (impossible once the specs // live under the admin-gated .zddc.d/). The client still walks the directory // for ROW files — only the SPEC is injected. Shape: // // { "spec": , "rowSchema": } // // Empty {} when neither resolves (the client then walks for the spec too, // preserving legacy behavior). Returns an error only if the placeholder is // absent from the template. func injectTableContext(template, tableYAML, formYAML []byte) ([]byte, error) { ctx := map[string]interface{}{} if len(tableYAML) > 0 { var spec interface{} if err := yaml.Unmarshal(tableYAML, &spec); err == nil && spec != nil { ctx["spec"] = spec } } if len(formYAML) > 0 { var fs map[string]interface{} if err := yaml.Unmarshal(formYAML, &fs); err == nil { if sch, ok := fs["schema"]; ok { ctx["rowSchema"] = sch } } } js, err := json.Marshal(ctx) if err != nil { return nil, err } js = []byte(strings.ReplaceAll(string(js), "{}`) if !bytesContains(template, needle) { return nil, errBundle("#table-context placeholder not found in template") } replacement := append([]byte(``)...) return bytesReplace(template, needle, replacement), nil } // injectTableContextObj writes a fully pre-assembled table context (title, // columns, rows, apiActions, …) into the `#table-context` placeholder, so the // client renders it as-is with no directory walk (context.js treats a context // carrying a columns[] array as authoritative). Used to render dynamic // server-side collections — e.g. the token list at /.tokens — through the same // tables engine + chrome as on-disk tables, instead of a bespoke page. func injectTableContextObj(template []byte, ctx interface{}) ([]byte, error) { js, err := json.Marshal(ctx) if err != nil { return nil, err } js = []byte(strings.ReplaceAll(string(js), "{}`) if !bytesContains(template, needle) { return nil, errBundle("#table-context placeholder not found in template") } replacement := append([]byte(``)...) return bytesReplace(template, needle, replacement), nil } // resolveDynamicEnums fills in `enum` for any column that declares // `options_source: ` (a configurable column key) from that peer // registry's live entries under the project root. Example: the risk // register's `package` column with `options_source: ssr` gets a dropdown of // the project's registered SSR packages. Columns without the key are // untouched; tableYAML is returned unchanged when there's nothing to resolve. func resolveDynamicEnums(fsRoot, dir string, tableYAML []byte) []byte { if len(tableYAML) == 0 { return tableYAML } var spec map[string]interface{} if err := yaml.Unmarshal(tableYAML, &spec); err != nil { return tableYAML } cols, ok := spec["columns"].([]interface{}) if !ok { return tableYAML } changed := false for _, c := range cols { col, ok := c.(map[string]interface{}) if !ok { continue } src, _ := col["options_source"].(string) if src == "" { continue } entries := registryEntries(fsRoot, dir, src) if entries == nil { continue } arr := make([]interface{}, len(entries)) for i, e := range entries { arr[i] = e } col["enum"] = arr changed = true } if !changed { return tableYAML } out, err := yaml.Marshal(spec) if err != nil { return tableYAML } return out } // registryEntries lists the names registered under // — the // .yaml row files (basename sans extension) and any subdirectories — sorted // and de-duped, skipping dot/underscore names and the table/form specs. The // project is the first path segment of dir under fsRoot. Returns nil when the // peer directory doesn't exist. func registryEntries(fsRoot, dir, peer string) []string { rel, err := filepath.Rel(fsRoot, dir) if err != nil || strings.HasPrefix(rel, "..") { return nil } rel = filepath.ToSlash(rel) project := rel if i := strings.IndexByte(rel, '/'); i >= 0 { project = rel[:i] } if project == "" || project == "." { return nil } ents, err := os.ReadDir(filepath.Join(fsRoot, project, peer)) if err != nil { return nil } seen := map[string]bool{} var names []string for _, e := range ents { name := e.Name() if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { continue } if name == "table.yaml" || name == "form.yaml" { continue } base := name if !e.IsDir() { low := strings.ToLower(name) if !strings.HasSuffix(low, ".yaml") && !strings.HasSuffix(low, ".yml") { continue } base = strings.TrimSuffix(strings.TrimSuffix(name, ".yml"), ".yaml") } if base == "" || seen[base] { continue } seen[base] = true names = append(names, base) } sort.Strings(names) return names } // EmbeddedTablesHTML exposes the embedded tables renderer to sibling handlers // (e.g. the token page) that render a server-injected collection through it. func EmbeddedTablesHTML() []byte { return embeddedTablesHTML } type errBundle string func (e errBundle) Error() string { return string(e) } // ServeTable serves the tables HTML for a recognized request, ACL-gated on // read at the request directory. The resolved table.yaml + form.yaml (from // .zddc.d/, legacy root, or the embedded default) are injected as // #table-context so the client never fetches the spec over HTTP. If the // template predates the placeholder, the bare HTML is served (the client // falls back to fetching) — keeps this non-breaking before ./build. func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *http.Request) { p := PrincipalFromContext(r) decider := DeciderFromContext(r) chain, err := zddc.EffectivePolicy(cfg.Root, req.Dir) if err != nil { slog.Warn("table: policy error", "path", req.Dir, "err", err) } if allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, r.URL.Path, policy.ActionRead); !allowed { writeForbidden(w, policy.ActionRead) return } if len(embeddedTablesHTML) == 0 { http.Error(w, "table renderer not built into this binary", http.StatusServiceUnavailable) return } body := embeddedTablesHTML tableYAML := LoadViewSpec(cfg.Root, req.Dir, "table.yaml") formYAML := LoadViewSpec(cfg.Root, req.Dir, "form.yaml") // Resolve any column whose options come from a live registry // (column key `options_source`, e.g. the risk register's `package` // column sourced from the project's `ssr` packages) into a concrete // enum, so the row editor renders a dropdown of the current entries. tableYAML = resolveDynamicEnums(cfg.Root, req.Dir, tableYAML) if injected, ierr := injectTableContext(embeddedTablesHTML, tableYAML, formYAML); ierr == nil { body = injected } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(body) }