feat(dispatch): trailing slash → browse, no slash → canonical default tool

URL convention for directories under a project:

- <dir>/  (with trailing slash)  → browse (the directory view; same
                                     behaviour as today)
- <dir>   (without trailing slash) → the canonical default tool for
                                     that directory's context, served
                                     inline (no 301 hop)

Tool mapping via the new apps.DefaultAppAt(root, dir):

  - working/...               → mdedit
  - staging/...               → transmittal
  - archive/                  → archive
  - archive/<party>/          → archive
  - archive/<party>/incoming|received|issued/...  → archive
  - archive/<party>/mdl/...   → tables (the per-party MDL grid editor)

Directories outside the canonical layout (project root, scratch
folders) keep the legacy 301-to-trailing-slash redirect since no
default tool fits.

This generalises and replaces the bespoke
"GET archive/<party>/mdl/ → 302 mdl.table.html" redirect added in PR4.
The new dispatcher rule serves the table app inline at the bare-mdl
URL by routing through RecognizeTableRequest with the canonical
.table.html suffix appended; relative fetches resolve identically
because both URLs share the same parent directory.

Tests: TestDefaultAppAt covers all canonical positions plus
case-fold and out-of-tree edges. TestDispatchSlashRouting (replacing
the now-obsolete TestDispatchMdlRedirect) verifies the slash-vs-no-
slash distinction at every canonical folder + non-canonical
fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-07 11:26:32 -05:00
parent dc7bf8ab04
commit f7958d7b22
4 changed files with 177 additions and 61 deletions

View file

@ -529,23 +529,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return return
} }
// MDL convenience redirect: GET archive/<party>/mdl/ → mdl.table.html.
// The table app is the canonical view for the per-party Master
// Deliverables List. Direct navigation to the data folder lands on
// the grid editor; clients that want the raw row listing can still
// hit archive/<party>/mdl/ via the file API or with an explicit
// trailing-segment beyond mdl/.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && len(segments) == 4 {
if strings.EqualFold(segments[1], "archive") && strings.EqualFold(segments[3], "mdl") {
target := "/" + segments[0] + "/" + segments[1] + "/" + segments[2] + "/mdl.table.html"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusFound)
return
}
}
// Tables-system intercept: *.table.html is a virtual URL that the // Tables-system intercept: *.table.html is a virtual URL that the
// table handler renders inline, reading rows from a directory of // table handler renders inline, reading rows from a directory of
// *.yaml files declared in the directory's .zddc tables: map. // *.yaml files declared in the directory's .zddc tables: map.
@ -680,6 +663,37 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return return
} }
} }
// URL convention: trailing slash → browse (handled by
// ServeDirectory, which serves browse.html for HTML requests
// and JSON for application/json). No trailing slash → the
// canonical default tool for this directory's context, if any
// (mdedit under working/, transmittal under staging/, archive
// under archive/, tables under archive/<party>/mdl/). When no
// default applies, fall back to the historical redirect-to-
// trailing-slash behaviour.
if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot {
switch apps.DefaultAppAt(cfg.Root, absPath) {
case "tables":
// Tables aren't an apps-subsystem app — the table
// handler responds to <dir>/<name>.table.html. Serve
// the equivalent table view inline at the bare-mdl
// URL by routing through the handler with the
// canonical .table.html name appended.
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, urlPath+".table.html"); tr != nil {
handler.ServeTable(cfg, tr, w, r)
return
}
case "archive", "transmittal", "mdedit":
if appsSrv != nil {
app := apps.DefaultAppAt(cfg.Root, absPath)
if apps.AppAvailableAt(cfg.Root, absPath, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
appsSrv.Serve(w, r, app, chain, absPath)
return
}
}
}
}
if !strings.HasSuffix(urlPath, "/") { if !strings.HasSuffix(urlPath, "/") {
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently) http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
return return

View file

@ -373,11 +373,26 @@ func TestDispatchArchiveRedirect(t *testing.T) {
} }
} }
func TestDispatchMdlRedirect(t *testing.T) { func TestDispatchSlashRouting(t *testing.T) {
// Convention: <dir>/ → browse (directory view); <dir> → the canonical
// default tool for the directory (mdedit under working/, transmittal
// under staging/, archive under archive/, tables under
// archive/<party>/mdl/). Without a default app, no-slash falls
// through to the legacy 301-to-trailing-slash redirect.
root := t.TempDir() root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"), mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*@example.com\": rwcda\n") "acl:\n permissions:\n \"*\": rwcda\n")
mustMkdir(t, filepath.Join(root, "ProjectA", "archive", "Acme")) for _, sub := range []string{
"Project/working",
"Project/staging",
"Project/archive",
"Project/archive/Acme",
"Project/archive/Acme/incoming",
"Project/archive/Acme/mdl",
"Project/scratch",
} {
mustMkdir(t, filepath.Join(root, sub))
}
idx, err := archive.BuildIndex(root) idx, err := archive.BuildIndex(root)
if err != nil { if err != nil {
@ -389,58 +404,46 @@ func TestDispatchMdlRedirect(t *testing.T) {
EmailHeader: "X-Auth-Request-Email", EmailHeader: "X-Auth-Request-Email",
} }
ring := handler.NewLogRing(10) ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
cases := []struct { cases := []struct {
name string name string
path string path string
wantStatus int wantStatus int
wantLoc string wantNoRedirect bool
}{ }{
{ {"working no-slash → mdedit", "/Project/working", http.StatusOK, true},
"mdl trailing slash → table", {"working slash → browse", "/Project/working/", http.StatusOK, true},
"/ProjectA/archive/Acme/mdl/", {"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true},
http.StatusFound, {"staging slash → browse", "/Project/staging/", http.StatusOK, true},
"/ProjectA/archive/Acme/mdl.table.html", {"archive no-slash → archive", "/Project/archive", http.StatusOK, true},
}, {"archive slash → browse", "/Project/archive/", http.StatusOK, true},
{ {"archive/<party> no-slash → archive", "/Project/archive/Acme", http.StatusOK, true},
"case-fold MDL trailing slash", {"archive/<party> slash → browse", "/Project/archive/Acme/", http.StatusOK, true},
"/ProjectA/archive/Acme/MDL/", {"archive/<party>/mdl no-slash → tables", "/Project/archive/Acme/mdl", http.StatusOK, true},
http.StatusFound, {"archive/<party>/mdl slash → browse", "/Project/archive/Acme/mdl/", http.StatusOK, true},
"/ProjectA/archive/Acme/mdl.table.html", {"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true},
}, {"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true},
{ {"non-canonical no-slash → 301 to slash", "/Project/scratch", http.StatusMovedPermanently, false},
"case-fold ARCHIVE", {"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true},
"/ProjectA/Archive/Acme/mdl/", {"project root no-slash → 301 to slash", "/Project", http.StatusMovedPermanently, false},
http.StatusFound,
"/ProjectA/Archive/Acme/mdl.table.html",
},
{
"deeper than party-level mdl is NOT redirected",
"/ProjectA/archive/Acme/incoming/mdl/",
// Falls through to static-file pipeline; no folder exists there → 404.
http.StatusNotFound,
"",
},
{
"working/mdl is NOT redirected (not under archive)",
"/ProjectA/working/mdl/",
http.StatusNotFound,
"",
},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil) req := httptest.NewRequest(http.MethodGet, tc.path, nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req) dispatch(cfg, idx, ring, appsSrv, rec, req)
if rec.Code != tc.wantStatus { if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String()) t.Fatalf("path=%q status=%d, want %d; body=%s",
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
} }
if tc.wantLoc != "" { if tc.wantNoRedirect && rec.Code >= 300 && rec.Code < 400 {
if got := rec.Header().Get("Location"); got != tc.wantLoc { t.Errorf("path=%q unexpected redirect to %q",
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc) tc.path, rec.Header().Get("Location"))
}
} }
}) })
} }

View file

@ -68,3 +68,56 @@ func inAncestorWithName(root, requestDir string, names ...string) bool {
} }
return false return false
} }
// DefaultAppAt returns the canonical default tool name for requestDir,
// or "" if no specific tool fits. Used by the dispatcher to decide
// which app to serve at a directory URL with no trailing slash —
// trailing-slash URLs serve the browse app for any directory.
//
// Rules (case-insensitive on canonical folder names):
//
// - <project>/archive/<party>/mdl/... → "tables"
// - <project>/archive/ → "archive"
// - <project>/archive/<party>/... → "archive"
// - <project>/staging/... → "transmittal"
// - <project>/working/... → "mdedit"
// - any other directory → "" (no default)
//
// The mdl rule wins over the broader archive rule because the table
// editor is a more specific surface for browsing planned deliverables
// than the archive index.
//
// requestDir and root are absolute filesystem paths; requestDir must
// be under root (otherwise "" is returned).
func DefaultAppAt(root, requestDir string) string {
root = filepath.Clean(root)
requestDir = filepath.Clean(requestDir)
if requestDir == root {
return ""
}
rel, err := filepath.Rel(root, requestDir)
if err != nil || strings.HasPrefix(rel, "..") {
return ""
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) < 2 {
// Project root itself — no default tool.
return ""
}
// The project segment is parts[0]; canonical folder is parts[1].
canonical := strings.ToLower(parts[1])
switch canonical {
case "archive":
// Inside archive/. Check for the mdl sub-case at depth 4
// (parts: project, archive, party, mdl).
if len(parts) >= 4 && strings.EqualFold(parts[3], "mdl") {
return "tables"
}
return "archive"
case "staging":
return "transmittal"
case "working":
return "mdedit"
}
return ""
}

View file

@ -67,3 +67,49 @@ func TestAppAvailableAt(t *testing.T) {
}) })
} }
} }
func TestDefaultAppAt(t *testing.T) {
root := "/srv/zddc"
cases := []struct {
dir string
want string
}{
// At the deployment root itself, no default tool — landing handles
// the project picker via a separate path.
{root, ""},
// Bare project root: no default. Trailing-slash URL serves browse;
// no-slash falls through to the redirect.
{root + "/Project-A", ""},
// Canonical project-root folders.
{root + "/Project-A/working", "mdedit"},
{root + "/Project-A/working/alice@example.com", "mdedit"},
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "mdedit"},
{root + "/Project-A/staging", "transmittal"},
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"},
// archive: at the archive root, party folders, and per-party
// subfolders (incoming/received/issued).
{root + "/Project-A/archive", "archive"},
{root + "/Project-A/archive/Acme", "archive"},
{root + "/Project-A/archive/Acme/incoming", "archive"},
{root + "/Project-A/archive/Acme/issued", "archive"},
{root + "/Project-A/archive/Acme/received", "archive"},
// mdl wins over the broader archive rule.
{root + "/Project-A/archive/Acme/mdl", "tables"},
{root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"},
// reviewing/ is virtual — no default tool wired here yet.
{root + "/Project-A/reviewing", ""},
// Random non-canonical folder names → no default.
{root + "/Project-A/scratch", ""},
// Case-fold on canonical names.
{root + "/Project-A/Working", "mdedit"},
{root + "/Project-A/STAGING", "transmittal"},
{root + "/Project-A/Archive/Acme/MDL", "tables"},
}
for _, tc := range cases {
t.Run(tc.dir, func(t *testing.T) {
if got := DefaultAppAt(root, tc.dir); got != tc.want {
t.Errorf("DefaultAppAt(%q) = %q, want %q", tc.dir, got, tc.want)
}
})
}
}