feat(zddc-server): apps section in .zddc editor

Extends the form-based .zddc editor at /.profile/zddc/edit?path=<dir>
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:
<path>" 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.<name> 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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-01 15:25:42 -05:00
parent 8b6a2dc3e3
commit 7b764956bd
3 changed files with 167 additions and 5 deletions

View file

@ -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=<dir>. The form posts JSON back to
// /.profile/zddc?path=<dir>; 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(`<!DOCTYPE html>
.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(`<!DOCTYPE html>
<button type="button" class="add" data-target="admins">+ Add admin</button>
</section>
<section class="card">
<h2>Apps (tool HTML sources)</h2>
<p class="help">
Override which build of each tool the server serves at this directory and below.
Spec is one of: <code>stable</code> / <code>beta</code> / <code>alpha</code>,
<code>v0.0.4</code> / <code>v0.0</code> / <code>v0</code> (canonical upstream),
<code>https://my-mirror/releases</code> (URL prefix — composes with channel from <code>default</code>),
<code>https://my-mirror/releases:beta</code> (URL prefix + channel),
<code>:beta</code> (channel-only override of <code>default</code>'s URL),
<code>https://my-fork/archive.html</code> (terminal full URL),
<code>./local.html</code> or <code>/abs/path.html</code> (terminal local file).
Leave any row blank to inherit from a parent <code>.zddc</code> file.
The <code>default</code> row provides the baseline URL prefix and channel for any app not overridden per-name.
</p>
<p class="help muted">
Per-request override: any user can append <code>?v=&lt;spec&gt;</code> to a tool URL (e.g. <code>?v=beta</code>, <code>?v=v0.0.4</code>, <code>?v=:alpha</code>) to ask for a different build for one request. <strong>Security:</strong> <code>?v=</code> serves only versions already in the cache (<code>&lt;ZDDC_ROOT&gt;/_app/</code>); cache misses return 404 so users can't trigger arbitrary upstream fetches. Local-path specs are also rejected from <code>?v=</code>.
</p>
<table class="apps-table">
<thead><tr><th class="k">Key</th><th class="v">Value</th><th class="r">Resolves to</th></tr></thead>
<tbody>
{{ range .AppsRows }}<tr>
<td class="k"><code>{{ .Key }}</code></td>
<td class="v"><input type="text" data-apps-key="{{ .Key }}" value="{{ .Value }}" placeholder="(inherit)"><span class="err"></span></td>
<td class="r"><span class="muted">{{ .ResolvesTo }}</span></td>
</tr>{{ end }}
</tbody>
</table>
<p class="help muted">The <em>Resolves to</em> column reflects the saved state of the cascade save and reload to see how edits compose.</p>
</section>
<section class="card chain">
<details {{ if not .Exists }}open{{ end }}>
<summary>Effective chain (inherited rules)</summary>
@ -269,11 +340,16 @@ admins:{{ range .Admins }} {{ . }}{{ end }}</pre></details>{{ 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 }}</pre></details>{{ end }}
function showErrors(errs) {
errs.forEach(function(e) {
// Apps fields look like "apps.<key>" — 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 }}</pre></details>{{ 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);
}
});

View file

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

View file

@ -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",