From 821ed3ee192f442e93d7d0a2e5e59565e3098d6b Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 7 May 2026 09:26:53 -0500 Subject: [PATCH] =?UTF-8?q?feat(handler):=20mdl/=20=E2=86=92=20table-app?= =?UTF-8?q?=20default=20with=20embedded=20fallback=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces wire the per-party Master Deliverables List as the default view at archive//mdl/: 1. **Dispatcher redirect.** GET (and HEAD) on /archive//mdl/ (case-fold on archive and mdl) now 302 → /archive//mdl.table.html. Non-archive paths and deeper mdl/ paths fall through unchanged. 2. **Default-spec fallback in RecognizeTableRequest.** When a request matches archive//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// mdl.table.yaml and archive//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//{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//, 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) --- zddc/cmd/zddc-server/main.go | 52 ++++++- zddc/cmd/zddc-server/main_test.go | 73 ++++++++++ zddc/internal/handler/default-mdl.form.yaml | 48 +++++++ zddc/internal/handler/default-mdl.table.yaml | 41 ++++++ zddc/internal/handler/tablehandler.go | 123 ++++++++++++++-- zddc/internal/handler/tablehandler_test.go | 141 +++++++++++++++++++ 6 files changed, 461 insertions(+), 17 deletions(-) create mode 100644 zddc/internal/handler/default-mdl.form.yaml create mode 100644 zddc/internal/handler/default-mdl.table.yaml diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 3af56a7..46fdc03 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -529,6 +529,23 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } + // MDL convenience redirect: GET archive//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//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 // table handler renders inline, reading rows from a directory of // *.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 // declaration and the URL falls through to the static-file path // (or to the form intercept below for *.form.html / *.yaml.html). + // + // One exception: archive//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 { handler.ServeTable(cfg, tableReq, w, r) return @@ -596,11 +617,34 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps info, err := os.Stat(absPath) if err != nil { if os.IsNotExist(err) { + // Default MDL spec fallback: archive//mdl.table.yaml + // and archive//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 - // the five canonical app HTML names AND the request directory - // is one where that app is available (Incoming/Working/Staging - // for classifier/mdedit/transmittal, anywhere for archive, - // root only for landing), resolve via the apps subsystem. + // the canonical app HTML names AND the request directory is + // one where that app is available (working/staging/incoming + // for classifier, working for mdedit, staging for + // transmittal, anywhere for archive, root only for landing), + // resolve via the apps subsystem. if appsSrv != nil { if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" { requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel)) diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 1998032..7512a6e 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -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 // 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 diff --git a/zddc/internal/handler/default-mdl.form.yaml b/zddc/internal/handler/default-mdl.form.yaml new file mode 100644 index 0000000..4bd855e --- /dev/null +++ b/zddc/internal/handler/default-mdl.form.yaml @@ -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//. 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 diff --git a/zddc/internal/handler/default-mdl.table.yaml b/zddc/internal/handler/default-mdl.table.yaml new file mode 100644 index 0000000..2bbec18 --- /dev/null +++ b/zddc/internal/handler/default-mdl.table.yaml @@ -0,0 +1,41 @@ +# Default Master Deliverables List spec, served by zddc-server when no +# operator-supplied mdl.table.yaml exists at archive//. Operators +# can override per-party by writing their own file at +# archive//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 } diff --git a/zddc/internal/handler/tablehandler.go b/zddc/internal/handler/tablehandler.go index 9845336..0463387 100644 --- a/zddc/internal/handler/tablehandler.go +++ b/zddc/internal/handler/tablehandler.go @@ -41,6 +41,66 @@ import ( //go:embed tables.html 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//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// +// 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 /archive// (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, "/") + // /archive// = 4 segments + if len(parts) != 4 { + return false + } + return strings.EqualFold(parts[1], "archive") +} + // TableRequest describes a recognized table-system request. type TableRequest struct { // 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") zf, err := zddc.ParseFile(zddcPath) - if err != nil { + if err != nil && !isNotExistError(err) { // Malformed .zddc — log and pass through; static handler will 500 // if it cares. Recognition just says "not a declared table here." slog.Warn("table: .zddc parse error", "path", zddcPath, "err", err) return nil } - specRel, ok := zf.Tables[name] - if !ok { - return nil + if specRel, ok := zf.Tables[name]; ok { + // Operator explicitly declared this table — honour it strictly. + // 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)) - if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot { - return nil + // No operator declaration — apply the default MDL spec fallback at + // archive//. The spec bytes are served by IsDefaultMdlSpec via + // 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 /archive// +// 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{ - Name: name, - SpecPath: specAbs, - Dir: dirAbs, + rel = filepath.ToSlash(rel) + if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." { + return false } + 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 diff --git a/zddc/internal/handler/tablehandler_test.go b/zddc/internal/handler/tablehandler_test.go index ccebca0..7b1442e 100644 --- a/zddc/internal/handler/tablehandler_test.go +++ b/zddc/internal/handler/tablehandler_test.go @@ -227,3 +227,144 @@ tables: t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String()) } } + +// --- default MDL spec fallback --------------------------------------------- + +// archivePartyTestSetup builds a minimal Project/archive// 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, "/archive//. 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) + } + } +} +