feat(handler): mdl/ → table-app default with embedded fallback spec

Three pieces wire the per-party Master Deliverables List as the default
view at archive/<party>/mdl/:

1. **Dispatcher redirect.** GET (and HEAD) on
   <project>/archive/<party>/mdl/ (case-fold on archive and mdl) now
   302 → <project>/archive/<party>/mdl.table.html. Non-archive paths
   and deeper mdl/ paths fall through unchanged.

2. **Default-spec fallback in RecognizeTableRequest.** When a request
   matches archive/<party>/mdl.table.html and no operator-supplied
   tables: { mdl: ... } declaration covers it, the handler returns a
   recognised request anyway. Operator declarations still win — and a
   typo'd declaration pointing at a missing file yields 404 (not a
   silent fallback).

3. **Static-file fallback for the spec yaml.** GET archive/<party>/
   mdl.table.yaml and archive/<party>/mdl.form.yaml return embedded
   default bytes (default-mdl.{table,form}.yaml in the handler package)
   when no operator file exists at that path. Operator files always
   win because the dispatcher's os.Stat finds them before reaching the
   IsDefaultMdlSpec branch.

The defaults use ZDDC vocabulary: tracking, title, discipline, type,
plannedRevision, plannedDate, status (DFT/IFR/IFA/IFC/AFC/AB), owner,
notes. Operators override per-party by writing
archive/<party>/{mdl.table.yaml,mdl.form.yaml} and a tables: { mdl: ... }
entry in the party's .zddc.

Tests:
- 4 dispatcher redirect cases (success, case-fold mdl, case-fold archive,
  deeper-path skip, non-archive skip)
- 6 tablehandler cases (default fires at archive/<party>/, operator
  override wins, scope check, embedded yaml served, operator yaml wins,
  scope check on yaml fallback)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-07 09:26:53 -05:00
parent ce108e1eb3
commit 821ed3ee19
6 changed files with 461 additions and 17 deletions

View file

@ -529,6 +529,23 @@ 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.
@ -536,6 +553,10 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// so RecognizeTableRequest returns nil whenever there's no matching // so RecognizeTableRequest returns nil whenever there's no matching
// declaration and the URL falls through to the static-file path // declaration and the URL falls through to the static-file path
// (or to the form intercept below for *.form.html / *.yaml.html). // (or to the form intercept below for *.form.html / *.yaml.html).
//
// One exception: archive/<party>/mdl.table.html falls back to the
// embedded default MDL spec when no operator declaration exists.
// RecognizeTableRequest implements that fallback internally.
if tableReq := handler.RecognizeTableRequest(cfg.Root, r.Method, urlPath); tableReq != nil { if tableReq := handler.RecognizeTableRequest(cfg.Root, r.Method, urlPath); tableReq != nil {
handler.ServeTable(cfg, tableReq, w, r) handler.ServeTable(cfg, tableReq, w, r)
return return
@ -596,11 +617,34 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
info, err := os.Stat(absPath) info, err := os.Stat(absPath)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
// Default MDL spec fallback: archive/<party>/mdl.table.yaml
// and archive/<party>/mdl.form.yaml are served from embedded
// bytes when no operator file exists on disk. The table app
// fetches these client-side; the fallback lets a fresh
// project work out of the box.
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if bytes, ok := handler.IsDefaultMdlSpec(cfg.Root, urlPath); ok {
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-ZDDC-Source", "default-mdl-spec")
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(bytes)
return
}
}
// File doesn't exist at this path. If the URL matches one of // File doesn't exist at this path. If the URL matches one of
// the five canonical app HTML names AND the request directory // the canonical app HTML names AND the request directory is
// is one where that app is available (Incoming/Working/Staging // one where that app is available (working/staging/incoming
// for classifier/mdedit/transmittal, anywhere for archive, // for classifier, working for mdedit, staging for
// root only for landing), resolve via the apps subsystem. // transmittal, anywhere for archive, root only for landing),
// resolve via the apps subsystem.
if appsSrv != nil { if appsSrv != nil {
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" { if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel)) requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))

View file

@ -373,6 +373,79 @@ func TestDispatchArchiveRedirect(t *testing.T) {
} }
} }
func TestDispatchMdlRedirect(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*@example.com\": rwcda\n")
mustMkdir(t, filepath.Join(root, "ProjectA", "archive", "Acme"))
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
}
ring := handler.NewLogRing(10)
cases := []struct {
name string
path string
wantStatus int
wantLoc string
}{
{
"mdl trailing slash → table",
"/ProjectA/archive/Acme/mdl/",
http.StatusFound,
"/ProjectA/archive/Acme/mdl.table.html",
},
{
"case-fold MDL trailing slash",
"/ProjectA/archive/Acme/MDL/",
http.StatusFound,
"/ProjectA/archive/Acme/mdl.table.html",
},
{
"case-fold ARCHIVE",
"/ProjectA/Archive/Acme/mdl/",
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 {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantLoc != "" {
if got := rec.Header().Get("Location"); got != tc.wantLoc {
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
}
}
})
}
}
// TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on // TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on
// any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file // any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file
// API's write path, so a write to an archive URL never silently mutates // API's write path, so a write to an archive URL never silently mutates

View file

@ -0,0 +1,48 @@
# Default row schema for a Master Deliverables List entry. Served by
# zddc-server when no operator-supplied mdl.form.yaml exists at
# archive/<party>/. Operators can override per-party.
title: Deliverable
description: One planned or in-flight deliverable for this party.
schema:
type: object
required: [tracking, title]
additionalProperties: false
properties:
tracking:
type: string
title: Tracking number
description: ZDDC tracking identifier (e.g. proj-EM-SPC-0001).
minLength: 1
title:
type: string
title: Deliverable title
minLength: 1
discipline:
type: string
title: Discipline
description: Engineering discipline code (EM, EL, MC, ST, ...).
type:
type: string
title: Document type
description: Code for document class (SPC, DWG, RPT, ...).
plannedRevision:
type: string
title: Planned revision
description: Issue revision label, e.g. A, B, IFR, IFC.
plannedDate:
type: string
title: Planned date
format: date
status:
type: string
title: Current status
enum: [DFT, IFR, IFA, IFC, AFC, AB]
owner:
type: string
title: Owner
description: Email or party name responsible for producing this row.
notes:
type: string
title: Notes

View file

@ -0,0 +1,41 @@
# Default Master Deliverables List spec, served by zddc-server when no
# operator-supplied mdl.table.yaml exists at archive/<party>/. Operators
# can override per-party by writing their own file at
# archive/<party>/mdl.table.yaml plus a tables: { mdl: ./mdl.table.yaml }
# entry in the party's .zddc.
title: Master Deliverables List
description: Planned and actual deliverables for this party.
rowSchema: ./mdl.form.yaml
rows: ./mdl
columns:
- field: tracking
title: Tracking
width: 11em
sort: asc
- field: title
title: Deliverable
- field: discipline
title: Disc.
width: 5em
- field: type
title: Type
width: 6em
- field: plannedRevision
title: Rev.
width: 5em
- field: plannedDate
title: Planned
format: date
- field: status
title: Status
width: 6em
enum: [DFT, IFR, IFA, IFC, AFC, AB]
- field: owner
title: Owner
defaults:
sort:
- { field: plannedDate, dir: asc }

View file

@ -41,6 +41,66 @@ import (
//go:embed tables.html //go:embed tables.html
var embeddedTablesHTML []byte var embeddedTablesHTML []byte
//go:embed default-mdl.table.yaml
var embeddedDefaultMdlTable []byte
//go:embed default-mdl.form.yaml
var embeddedDefaultMdlForm []byte
// DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes.
// Used by the static-file handler to serve the default spec at
// archive/<party>/mdl.table.yaml when no operator file exists on disk.
func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable }
// DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes.
func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm }
// IsDefaultMdlSpec reports whether (urlPath, dirAbs) describes a request
// for the default mdl.table.yaml or mdl.form.yaml under archive/<party>/
// where no operator file exists. Caller is the static-file handler.
//
// Returns the embedded bytes + true when the fallback should fire.
// Returns nil + false when an operator-supplied file exists or the path
// is not eligible for the fallback.
func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) {
base := strings.ToLower(filepath.Base(urlPath))
var bytes []byte
switch base {
case "mdl.table.yaml":
bytes = embeddedDefaultMdlTable
case "mdl.form.yaml":
bytes = embeddedDefaultMdlForm
default:
return nil, false
}
if !isAtArchivePartyLevel(fsRoot, urlPath) {
return nil, false
}
// Operator file wins if it exists on disk.
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
}
if fileExists(abs) {
return nil, false
}
return bytes, true
}
// isAtArchivePartyLevel reports whether urlPath refers to a file
// directly under <project>/archive/<party>/ (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, "/")
// <project>/archive/<party>/<file> = 4 segments
if len(parts) != 4 {
return false
}
return strings.EqualFold(parts[1], "archive")
}
// TableRequest describes a recognized table-system request. // TableRequest describes a recognized table-system request.
type TableRequest struct { type TableRequest struct {
// Name is the table's URL stem (the key declared in .zddc tables). // Name is the table's URL stem (the key declared in .zddc tables).
@ -86,28 +146,65 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
} }
zddcPath := filepath.Join(dirAbs, ".zddc") zddcPath := filepath.Join(dirAbs, ".zddc")
zf, err := zddc.ParseFile(zddcPath) zf, err := zddc.ParseFile(zddcPath)
if err != nil { if err != nil && !isNotExistError(err) {
// Malformed .zddc — log and pass through; static handler will 500 // Malformed .zddc — log and pass through; static handler will 500
// if it cares. Recognition just says "not a declared table here." // if it cares. Recognition just says "not a declared table here."
slog.Warn("table: .zddc parse error", "path", zddcPath, "err", err) slog.Warn("table: .zddc parse error", "path", zddcPath, "err", err)
return nil return nil
} }
specRel, ok := zf.Tables[name] if specRel, ok := zf.Tables[name]; ok {
if !ok { // Operator explicitly declared this table — honour it strictly.
return nil // If the declared spec file is missing, return nil so the URL
// 404s rather than silently falling back to the default. This
// keeps a typo in the operator's .zddc visible.
specAbs := filepath.Join(dirAbs, filepath.FromSlash(specRel))
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot {
return nil
}
if !fileExists(specAbs) {
return nil
}
return &TableRequest{
Name: name,
SpecPath: specAbs,
Dir: dirAbs,
}
} }
specAbs := filepath.Join(dirAbs, filepath.FromSlash(specRel)) // No operator declaration — apply the default MDL spec fallback at
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot { // archive/<party>/. The spec bytes are served by IsDefaultMdlSpec via
return nil // the static-file dispatcher.
if strings.EqualFold(name, "mdl") && isArchivePartyDir(fsRoot, dirAbs) {
return &TableRequest{
Name: "mdl",
SpecPath: filepath.Join(dirAbs, "mdl.table.yaml"), // virtual; static handler may serve embedded bytes
Dir: dirAbs,
}
} }
if !fileExists(specAbs) { return nil
return nil }
// 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")
}
// isArchivePartyDir reports whether dirAbs is a <project>/archive/<party>/
// directory under fsRoot, with archive case-folded.
func isArchivePartyDir(fsRoot, dirAbs string) bool {
rel, err := filepath.Rel(fsRoot, dirAbs)
if err != nil {
return false
} }
return &TableRequest{ rel = filepath.ToSlash(rel)
Name: name, if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." {
SpecPath: specAbs, return false
Dir: dirAbs,
} }
parts := strings.Split(rel, "/")
if len(parts) != 3 {
return false
}
return strings.EqualFold(parts[1], "archive")
} }
// ServeTable serves the static tables.html bytes for a recognized // ServeTable serves the static tables.html bytes for a recognized

View file

@ -227,3 +227,144 @@ tables:
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String()) t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
} }
} }
// --- default MDL spec fallback ---------------------------------------------
// archivePartyTestSetup builds a minimal Project/archive/<party>/ tree
// with no operator-supplied tables: declaration. RecognizeTableRequest
// should still fire for "mdl" thanks to the default-spec fallback.
func archivePartyTestSetup(t *testing.T, partyZddcExtras string) (string, func(method, target, email string) *httptest.ResponseRecorder) {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
t.Fatal(err)
}
partyDir := filepath.Join(root, "Project", "archive", "Acme")
if err := os.MkdirAll(partyDir, 0o755); err != nil {
t.Fatal(err)
}
if partyZddcExtras != "" {
if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), []byte(partyZddcExtras), 0o644); err != nil {
t.Fatal(err)
}
}
zddc.InvalidateCache(root)
cfg := config.Config{
Root: root,
EmailHeader: "X-Auth-Request-Email",
}
do := func(method, target, email string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, bytes.NewReader(nil))
ctx := context.WithValue(req.Context(), EmailKey, email)
req = req.WithContext(ctx)
tr := RecognizeTableRequest(cfg.Root, method, target)
rec := httptest.NewRecorder()
if tr == nil {
rec.WriteHeader(http.StatusNotFound)
return rec
}
ServeTable(cfg, tr, rec, req)
return rec
}
return root, do
}
func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) {
_, do := archivePartyTestSetup(t, "")
rec := do(http.MethodGet, "/Project/archive/Acme/mdl.table.html", "alice@example.com")
if rec.Code != http.StatusOK {
t.Fatalf("default mdl recognition: want 200, got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "<html") {
t.Errorf("expected tables.html bytes, got %q…", body[:min(80, len(body))])
}
}
func TestRecognizeTableRequest_OperatorOverrideWins(t *testing.T) {
// Operator declared a custom mdl spec that points at a non-existent
// file. The fallback should NOT fire, because the operator
// explicitly took control. RecognizeTableRequest returns nil.
root, do := archivePartyTestSetup(t, `tables:
mdl: ./custom-mdl.table.yaml
`)
_ = root
rec := do(http.MethodGet, "/Project/archive/Acme/mdl.table.html", "alice@example.com")
if rec.Code != http.StatusNotFound {
t.Errorf("operator declaration with missing spec should fall through to 404, got %d", rec.Code)
}
}
func TestRecognizeTableRequest_DefaultOnlyAtPartyLevel(t *testing.T) {
// Default fallback is scoped to <project>/archive/<party>/. A
// request at a deeper path (e.g. archive/Acme/mdl/sub/) or a
// non-archive path should return nil (no recognition).
_, do := archivePartyTestSetup(t, "")
rec := do(http.MethodGet, "/Project/archive/Acme/incoming/mdl.table.html", "alice@example.com")
if rec.Code != http.StatusNotFound {
t.Errorf("mdl deeper than party level should not recognise; got %d", rec.Code)
}
rec = do(http.MethodGet, "/Project/working/mdl.table.html", "alice@example.com")
if rec.Code != http.StatusNotFound {
t.Errorf("mdl outside archive/ should not recognise; got %d", rec.Code)
}
}
func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
root := t.TempDir()
// archive/Acme/ exists but no mdl.table.yaml on disk.
if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil {
t.Fatal(err)
}
bts, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.table.yaml")
if !ok {
t.Fatalf("expected fallback to fire")
}
if !strings.Contains(string(bts), "Master Deliverables List") {
t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
}
bts, ok = IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.form.yaml")
if !ok {
t.Fatalf("expected form fallback to fire")
}
if !strings.Contains(string(bts), "Deliverable") {
t.Errorf("default form spec missing expected title")
}
}
func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "Project", "archive", "Acme")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "mdl.table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.table.yaml"); ok {
t.Errorf("operator file should win over embedded fallback")
}
}
func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) {
root := t.TempDir()
cases := []string{
"/Project/working/mdl.table.yaml",
"/Project/archive/mdl.table.yaml", // depth 3 — no party segment
"/Project/archive/Acme/sub/mdl.table.yaml",
}
for _, p := range cases {
if _, ok := IsDefaultMdlSpec(root, p); ok {
t.Errorf("path %q should NOT trigger default fallback", p)
}
}
}