From 7b764956bdd654dd8707a8af1ffcec4063373adb Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 1 May 2026 15:25:42 -0500 Subject: [PATCH] feat(zddc-server): apps section in .zddc editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the form-based .zddc editor at /.profile/zddc/edit?path= with an Apps section between Admins and the Effective chain. The section is a six-row table — default plus the five canonical apps — with one text input per row. Each row's right column shows a server- rendered "Resolves to" preview computed by walking the cascade through this directory and applying default + per-app composition. The preview displays the final URL, "embedded (build-time default)", or "local file: " so operators see exactly what will be served. Help text covers the full spec syntax (channel/version/URL/path forms, :channel shorthand, default key) plus the ?v= per-request override and its cache-only security constraint. Permission gating is unchanged: existing CanEditZddc() strict-ancestor rule applies — subtree admins cannot edit the file that grants their own authority. Field-level errors land inline next to the input, just like the existing ACL/admins fields. POST handler (internal/handler/zddchandler.go) accepts a new Apps map in the JSON write request, validates via the existing zddc.ValidateFile flow (which now enforces apps. spec syntax), and writes atomically through the unchanged zddc.WriteFile path. Three new tests: round-trip apps including the default key, per-field validation error returns, and editor renders the apps section with existing .zddc values pre-filled. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/handler/zddceditor.go | 89 ++++++++++++++++++++++- zddc/internal/handler/zddchandler.go | 8 +- zddc/internal/handler/zddchandler_test.go | 75 +++++++++++++++++++ 3 files changed, 167 insertions(+), 5 deletions(-) diff --git a/zddc/internal/handler/zddceditor.go b/zddc/internal/handler/zddceditor.go index 6206a44..b040893 100644 --- a/zddc/internal/handler/zddceditor.go +++ b/zddc/internal/handler/zddceditor.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "codeberg.org/VARASYS/ZDDC/zddc/internal/apps" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) @@ -21,10 +22,20 @@ type editorView struct { HasCustomCSS bool File zddc.ZddcFile EffectiveChain []chainEntry + AppsRows []appsRow ProfilePathPrefix string // /.profile AssetsPathPrefix string // /.profile/zddc/assets } +// appsRow renders one line of the Apps section: the apps key (default or +// app name), its current value at THIS level (may be empty), and the +// preview of how it resolves once the cascade is applied. +type appsRow struct { + Key string // "default" or canonical app name + Value string // current spec at this .zddc level (empty = inherits) + ResolvesTo string // human-readable preview line +} + // serveZddcEditor renders the form-based .zddc editor at // GET /.profile/zddc/edit?path=. The form posts JSON back to // /.profile/zddc?path=; the inline JS shim handles dynamic-row @@ -70,6 +81,27 @@ func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request) }) } + // Apps rows: for default + each canonical app, show the current value at + // THIS level (zf.Apps[key]) and the resolved preview given the cascade. + // Default key first, then canonical apps in declared order. + keys := append([]string{zddc.AppsDefaultKey}, zddc.AppNames...) + rows := make([]appsRow, 0, len(keys)) + for _, k := range keys { + row := appsRow{Key: k, Value: zf.Apps[k]} + if k == zddc.AppsDefaultKey { + // "default" doesn't resolve to a single URL on its own — it's + // the baseline. Render a brief description. + if row.Value == "" { + row.ResolvesTo = "(unset — apps fall back to canonical " + apps.DefaultUpstreamReleases + " + " + apps.DefaultChannel + ")" + } else { + row.ResolvesTo = "baseline for any app not overridden below" + } + } else { + row.ResolvesTo = apps.PreviewLine(chain, k, cfg.Root, abs) + } + rows = append(rows, row) + } + view := editorView{ Path: urlPathOf(cfg.Root, abs), IsRoot: abs == cfg.Root, @@ -79,6 +111,7 @@ func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request) HasCustomCSS: hasCustomProfileCSS(cfg.Root), File: zf, EffectiveChain: entries, + AppsRows: rows, ProfilePathPrefix: ProfilePathPrefix, AssetsPathPrefix: zddcAssetsPathPrefix, } @@ -139,6 +172,14 @@ var editorTemplate = template.Must(template.New("editor").Parse(` .row { display: flex; gap: .5rem; align-items: center; margin-bottom: .35rem; } .row input[type="text"] { flex: 1; max-width: none; } .row .err { color: var(--danger); font-size: .85em; margin-left: .5rem; } + .apps-table { width: 100%; border-collapse: collapse; margin-top: .5rem; } + .apps-table th { text-align: left; font-weight: 600; padding: .35rem .5rem; border-bottom: 1px solid var(--border); color: var(--muted); font-size: .85em; } + .apps-table td { padding: .3rem .5rem; vertical-align: middle; border-bottom: 1px solid var(--border); } + .apps-table td.k { width: 8em; white-space: nowrap; } + .apps-table td.k code { font-weight: 600; } + .apps-table td.v input { max-width: none; width: 100%; } + .apps-table td.v .err { color: var(--danger); font-size: .85em; display: block; margin-top: .15rem; } + .apps-table td.r { font-size: .85em; word-break: break-all; } button { font: inherit; padding: .35rem .85rem; cursor: pointer; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: var(--radius); } button:hover { background: var(--primary-bg); } button.primary { background: var(--primary); color: white; border-color: var(--primary); } @@ -212,6 +253,36 @@ var editorTemplate = template.Must(template.New("editor").Parse(` +
+

Apps (tool HTML sources)

+

+ Override which build of each tool the server serves at this directory and below. + Spec is one of: stable / beta / alpha, + v0.0.4 / v0.0 / v0 (canonical upstream), + https://my-mirror/releases (URL prefix — composes with channel from default), + https://my-mirror/releases:beta (URL prefix + channel), + :beta (channel-only override of default's URL), + https://my-fork/archive.html (terminal full URL), + ./local.html or /abs/path.html (terminal local file). + Leave any row blank to inherit from a parent .zddc file. + The default row provides the baseline URL prefix and channel for any app not overridden per-name. +

+

+ Per-request override: any user can append ?v=<spec> to a tool URL (e.g. ?v=beta, ?v=v0.0.4, ?v=:alpha) to ask for a different build for one request. Security: ?v= serves only versions already in the cache (<ZDDC_ROOT>/_app/); cache misses return 404 so users can't trigger arbitrary upstream fetches. Local-path specs are also rejected from ?v=. +

+ + + + {{ range .AppsRows }} + + + + {{ end }} + +
KeyValueResolves to
{{ .Key }}{{ .ResolvesTo }}
+

The Resolves to column reflects the saved state of the cascade — save and reload to see how edits compose.

+
+
Effective chain (inherited rules) @@ -269,11 +340,16 @@ admins:{{ range .Admins }} {{ . }}{{ end }}
{{ end }} }); function collect() { - var out = { title: "", acl: { allow: [], deny: [] }, admins: [] }; + var out = { title: "", acl: { allow: [], deny: [] }, admins: [], apps: {} }; out.title = document.getElementById("f-title").value; document.querySelectorAll('.list[data-field="acl.allow"] input').forEach(function(i) { if (i.value.trim()) out.acl.allow.push(i.value.trim()); }); document.querySelectorAll('.list[data-field="acl.deny"] input').forEach(function(i) { if (i.value.trim()) out.acl.deny.push(i.value.trim()); }); document.querySelectorAll('.list[data-field="admins"] input').forEach(function(i) { if (i.value.trim()) out.admins.push(i.value.trim()); }); + document.querySelectorAll('input[data-apps-key]').forEach(function(i) { + var k = i.dataset.appsKey; + var v = i.value.trim(); + if (v) out.apps[k] = v; + }); return out; } @@ -284,6 +360,16 @@ admins:{{ range .Admins }} {{ . }}{{ end }}{{ end }} function showErrors(errs) { errs.forEach(function(e) { + // Apps fields look like "apps." — surface inline next to the row. + if (e.field.indexOf("apps.") === 0) { + var key = e.field.substring("apps.".length); + var input = document.querySelector('input[data-apps-key="' + CSS.escape(key) + '"]'); + if (input) { + var span = input.parentElement.querySelector(".err"); + if (span) span.textContent = e.message; + return; + } + } var sel = '[data-field="' + CSS.escape(e.field) + '"]'; var input = document.querySelector(sel); if (input) { @@ -291,7 +377,6 @@ admins:{{ range .Admins }} {{ . }}{{ end }}{{ end }} if (span) span.textContent = e.message; } else { // Top-level field error (e.g. "admins" without index, or "title"). - var card = document.querySelector('h2 + .help, [name="' + e.field + '"]'); alert(e.field + ": " + e.message); } }); diff --git a/zddc/internal/handler/zddchandler.go b/zddc/internal/handler/zddchandler.go index 644bc5b..2efd6c5 100644 --- a/zddc/internal/handler/zddchandler.go +++ b/zddc/internal/handler/zddchandler.go @@ -145,9 +145,10 @@ type zddcGetResponse struct { } type zddcWriteRequest struct { - Title string `json:"title"` - ACL zddc.ACLRules `json:"acl"` - Admins []string `json:"admins"` + Title string `json:"title"` + ACL zddc.ACLRules `json:"acl"` + Admins []string `json:"admins"` + Apps map[string]string `json:"apps,omitempty"` } type writeError struct { @@ -261,6 +262,7 @@ func serveZddcWrite(cfg config.Config, abs, email string, w http.ResponseWriter, Title: req.Title, ACL: req.ACL, Admins: req.Admins, + Apps: req.Apps, } if errs := zddc.ValidateFile(zf); len(errs) > 0 { diff --git a/zddc/internal/handler/zddchandler_test.go b/zddc/internal/handler/zddchandler_test.go index f401fdb..613637c 100644 --- a/zddc/internal/handler/zddchandler_test.go +++ b/zddc/internal/handler/zddchandler_test.go @@ -214,6 +214,81 @@ func TestServeZddcWriteRoundTrip(t *testing.T) { } } +func TestServeZddcWriteAppsRoundTrip(t *testing.T) { + _, do := zddcTestSetup(t, map[string]string{ + "": "admins:\n - root@example.com\n", + "projects": "", + }) + body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":[],"apps":{` + + `"default":"https://zddc.varasys.io/releases:stable",` + + `"classifier":":beta",` + + `"archive":"https://my.local.stuff/releases"}}` + rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body) + if rec.Code != http.StatusOK { + t.Fatalf("write status=%d body=%s", rec.Code, rec.Body.String()) + } + rec = do(http.MethodGet, "/.profile/zddc?path=/projects", "root@example.com", "") + if rec.Code != http.StatusOK { + t.Fatalf("get status=%d body=%s", rec.Code, rec.Body.String()) + } + var resp zddcGetResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if got := resp.File.Apps["default"]; got != "https://zddc.varasys.io/releases:stable" { + t.Errorf("default round-trip = %q", got) + } + if got := resp.File.Apps["classifier"]; got != ":beta" { + t.Errorf("classifier round-trip = %q", got) + } + if got := resp.File.Apps["archive"]; got != "https://my.local.stuff/releases" { + t.Errorf("archive round-trip = %q", got) + } +} + +func TestServeZddcWriteAppsRejectsBadSpec(t *testing.T) { + _, do := zddcTestSetup(t, map[string]string{ + "": "admins:\n - root@example.com\n", + "projects": "", + }) + body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":[],"apps":{"archive":"this is garbage"}}` + rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d (want 400)", rec.Code) + } + if !strings.Contains(rec.Body.String(), `"apps.archive"`) { + t.Errorf("expected per-field error for apps.archive; got %s", rec.Body.String()) + } +} + +func TestServeZddcEditorRendersAppsSection(t *testing.T) { + _, do := zddcTestSetup(t, map[string]string{ + "": "admins:\n - root@example.com\n", + "projects": "apps:\n default: \":beta\"\n classifier: \"v0.0.4\"\n", + }) + rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "root@example.com", "") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{ + "Apps (tool HTML sources)", + `data-apps-key="default"`, + `data-apps-key="archive"`, + `data-apps-key="classifier"`, + `data-apps-key="mdedit"`, + `data-apps-key="transmittal"`, + `data-apps-key="landing"`, + `value=":beta"`, + `value="v0.0.4"`, + "classifier_v0.0.4.html", // preview reflects the cascaded resolution + } { + if !strings.Contains(body, want) { + t.Errorf("editor body missing %q", want) + } + } +} + func TestServeZddcTreeFiltersByVisibility(t *testing.T) { _, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\n",