diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go
index 8f2669c..bb5829d 100644
--- a/zddc/cmd/zddc-server/main.go
+++ b/zddc/cmd/zddc-server/main.go
@@ -646,6 +646,17 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
+ // Raw .zddc YAML view:
/.zddc is reachable at every depth
+ // and returns the on-disk file's bytes (Content-Type: application/yaml)
+ // or — when no file exists — a synthetic placeholder body with a
+ // cascade summary so the user can see what's effective here.
+ // GET/HEAD only; writes go through the admin-gated .zddc.html
+ // form. Also carved out of the dot-prefix guard.
+ if handler.IsZddcFileRequest(urlPath) {
+ handler.ServeZddcFile(cfg, w, r)
+ return
+ }
+
// Reserve dot-prefixed path segments. The listing pipeline already hides
// hidden entries (internal/listing/listing.go:17, projectshandler.go:40),
// but direct URL access would still serve them. 404 here so hidden trees
@@ -874,23 +885,28 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
}
}
// Default-MDL virtual directory at archive//mdl[/].
- // The rows-dir doesn't have to exist on disk —
- // RecognizeTableRequest's default-MDL fallback handles a
- // fully-missing path so a fresh party with no entries yet
- // still lands on a usable table view (rather than 404).
- // Both slash and no-slash forms serve the tables app
- // directly; the slash form is the canonical URL the MDL
- // card on the project landing page links to.
+ // Shape rule mirrors the other canonical folders:
+ // - no slash → tables app (default tool for mdl/)
+ // - slash → browse (ServeDirectory → empty listing for
+ // the not-yet-materialised folder)
+ // The dispatcher works without the on-disk dir existing
+ // thanks to fs.ListDirectory's empty-listing fallback +
+ // RecognizeTableRequest's default-MDL fallback.
if r.Method == http.MethodGet || r.Method == http.MethodHead {
- base := strings.TrimSuffix(urlPath, "/")
- synth := base + "/table.html"
- if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil {
- chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
- if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
- http.Error(w, "Forbidden", http.StatusForbidden)
+ if !strings.HasSuffix(urlPath, "/") {
+ synth := urlPath + "/table.html"
+ if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil {
+ chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
+ if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+ handler.ServeTable(cfg, tr, w, r)
return
}
- handler.ServeTable(cfg, tr, w, r)
+ } else if zddc.IsArchivePartyMdlDir(
+ strings.Trim(strings.TrimPrefix(urlPath, "/"), "/")) {
+ handler.ServeDirectory(cfg, appsSrv, w, r)
return
}
}
diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go
index aef6c2a..a0bcf62 100644
--- a/zddc/internal/fs/tree.go
+++ b/zddc/internal/fs/tree.go
@@ -56,7 +56,8 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
// Returning [] makes the click land on a usable empty view; the
// virtualUserHomeEntry below still fires for working/ so the
// user sees their own home placeholder.
- if os.IsNotExist(err) && zddc.IsProjectRootFolder(dirPath) {
+ if os.IsNotExist(err) &&
+ (zddc.IsProjectRootFolder(dirPath) || zddc.IsArchivePartyMdlDir(dirPath)) {
entries = nil
} else {
return nil, err
diff --git a/zddc/internal/handler/directory_test.go b/zddc/internal/handler/directory_test.go
index a5d53c4..8ff2d81 100644
--- a/zddc/internal/handler/directory_test.go
+++ b/zddc/internal/handler/directory_test.go
@@ -204,10 +204,14 @@ func TestServeDirectoryRedirectsTableRowsDir(t *testing.T) {
})
}
-// TestServeDirectoryRedirectsDefaultMdl covers the default-MDL fallback:
-// archive//mdl/ with no on-disk table.yaml still redirects
-// to mdl/table.html (the table handler serves embedded defaults).
-func TestServeDirectoryRedirectsDefaultMdl(t *testing.T) {
+// TestServeDirectoryDefaultMdlNoRedirect covers the default-MDL case:
+// when no on-disk table.yaml exists, archive//mdl/ (slash form)
+// no longer redirects to mdl/table.html. Per the slash/no-slash
+// convention, the slash form is the browse view; the no-slash form
+// /Project/archive/Acme/mdl serves the tables app via the dispatcher.
+// User-declared tables with a real table.yaml on disk DO still
+// redirect (see TestServeDirectoryRedirectsRealTable).
+func TestServeDirectoryDefaultMdlNoRedirect(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
@@ -227,10 +231,9 @@ func TestServeDirectoryRedirectsDefaultMdl(t *testing.T) {
rec := httptest.NewRecorder()
ServeDirectory(cfg, nil, rec, req)
- if rec.Code != http.StatusFound {
- t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String())
- }
- if got, want := rec.Header().Get("Location"), "/Project/archive/Acme/mdl/table.html"; got != want {
- t.Errorf("Location = %q, want %q", got, want)
+ // Should NOT redirect to /table.html — slash form is browse, not tables.
+ if rec.Code == http.StatusFound {
+ t.Fatalf("got 302 redirect to %q, want browse view (200)",
+ rec.Header().Get("Location"))
}
}
diff --git a/zddc/internal/handler/tablehandler.go b/zddc/internal/handler/tablehandler.go
index fc7111b..c85c77e 100644
--- a/zddc/internal/handler/tablehandler.go
+++ b/zddc/internal/handler/tablehandler.go
@@ -165,7 +165,16 @@ func tableRowsRedirect(fsRoot, urlPath string) string {
urlPath += "/"
}
synthesized := urlPath + "table.html"
- if RecognizeTableRequest(fsRoot, http.MethodGet, synthesized) == nil {
+ tr := RecognizeTableRequest(fsRoot, http.MethodGet, synthesized)
+ if tr == nil {
+ return ""
+ }
+ // Default-MDL 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/ click.
+ if !fileExists(tr.SpecPath) {
return ""
}
return synthesized
diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html
index 6be8403..8a1c268 100644
--- a/zddc/internal/handler/tables.html
+++ b/zddc/internal/handler/tables.html
@@ -1300,7 +1300,7 @@ body.help-open .app-header {