diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 108a568..1ef0dae 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -1052,9 +1052,8 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // Default-spec fallback for the embedded table.yaml / form.yaml // files served when no operator file exists on disk: // - // /archive//{mdl,rsk}/{table,form}.yaml - // /archive//ssr.form.yaml - // /{ssr,mdl,rsk}/{table,form}.yaml + // /{ssr,mdl,rsk}/{table,form}.yaml (aggregate/registry) + // /{mdl,rsk}//{table,form}.yaml (per-party) // // The table app fetches these client-side; the fallback lets // a fresh project work out of the box. ACL gates against the @@ -1078,47 +1077,13 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } } - // Virtual project-level table views (SSR / MDL rollup / RSK - // rollup). The virtual row URL doesn't exist on disk; the - // underlying canonical file lives in /archive//. - // ACL evaluates against the canonical party-archive path so - // non-owners see the row read-only and party owners can edit. - if r.Method == http.MethodGet || r.Method == http.MethodHead { - if vv := zddc.ResolveVirtualView(cfg.Root, urlPath); vv.Resolved && vv.Kind.IsRowKind() { - chain, _ := zddc.EffectivePolicy(cfg.Root, vv.PartyArchive) - if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - handler.ServeVirtualViewRow(w, r, vv) - return - } - } - // Virtual folder-nav redirect. URLs of the shape - // //{working,staging,reviewing}/[/...] - // 302 to //archive//[/...] — the - // canonical physical path. The per-party folder-nav - // virtual itself has no on-disk presence; the redirect - // hands the client off to the real address so subsequent - // navigation, sharing, and bookmarks stay canonical. - if r.Method == http.MethodGet || r.Method == http.MethodHead { - if vv := zddc.ResolveVirtualView(cfg.Root, urlPath); vv.Resolved && vv.Kind == zddc.VirtualViewFolderNavRedir { - target := vv.CanonicalURL - // Preserve trailing slash from the request, since - // the canonical URL is a directory. - if strings.HasSuffix(urlPath, "/") && !strings.HasSuffix(target, "/") { - target += "/" - } - // Preserve query string verbatim — clients - // passing ?hidden=1 etc. should land at the same - // query on the canonical URL. - if q := r.URL.RawQuery; q != "" { - target += "?" + q - } - http.Redirect(w, r, target, http.StatusFound) - return - } - } + // (Register rows are real files now — ssr/.yaml and + // mdl|rsk//.yaml — so a GET of one hits the + // on-disk serve path below, where $party/name is injected; + // it never lands in this not-found branch. working/staging/ + // reviewing are real directories navigated normally. The old + // virtual-row serve + folder-nav 302 are gone.) + // File doesn't exist at this path. Before falling through to // app-HTML routing or 404, check the two virtual-file-extension // shapes that ZDDC exposes through the listing convention: @@ -1347,9 +1312,40 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } + // Register rows are real files; inject the path-derived source column + // ($party for mdl/rsk rows, name for ssr rows) on read so the tables + // tool renders it as a read-only column. The client strips it on save. + if r.Method == http.MethodGet || r.Method == http.MethodHead { + if field, value, ok := registerRowField(urlPath); ok { + handler.ServeInjectedRow(w, r, absPath, field, value) + return + } + } + handler.ServeFile(w, r, absPath) } +// registerRowField returns the path-derived column to inject when urlPath +// names an aggregate register row: ($party, ) for +// //{mdl,rsk}//.yaml, or (name, ) for +// //ssr/.yaml. ok=false otherwise (incl. spec files). +func registerRowField(urlPath string) (field, value string, ok bool) { + parts := strings.Split(strings.Trim(urlPath, "/"), "/") + switch len(parts) { + case 3: + if parts[1] == "ssr" && strings.HasSuffix(parts[2], ".yaml") && + parts[2] != "table.yaml" && parts[2] != "form.yaml" { + return "name", strings.TrimSuffix(parts[2], ".yaml"), true + } + case 4: + if (parts[1] == "mdl" || parts[1] == "rsk") && strings.HasSuffix(parts[3], ".yaml") && + parts[3] != "table.yaml" && parts[3] != "form.yaml" { + return "$party", parts[2], true + } + } + return "", "", false +} + // runPeriodicRescan calls idx.Rebuild on `interval` until ctx is cancelled. // Each tick walks fsRoot from scratch and atomically replaces the live index; // concurrent reads are safe via the index's RWMutex. Errors are logged but do diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index b898dc7..49ee170 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -197,125 +197,79 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, result = append(result, fi) } - // Per-user virtual home: when listing - // /archive//working/ for an authenticated viewer, - // surface a synthetic / entry if no real folder of - // any case variant already exists for them. A first write to that - // path materialises a real folder with auto-own .zddc; subsequent - // listings drop the synthetic entry naturally. - if syn, ok := virtualUserHomeEntry(ctx, decider, fsRoot, dirPath, principal, baseURL, result); ok { - result = append(result, syn) - } - - // At a project root, surface the cascade-declared top-level - // folders (archive plus the six virtual aggregators) as virtual + // At a project root, surface the cascade-declared top-level peers + // (archive + the party-partitioned workspaces/registers) as virtual // entries when no on-disk variant exists. The browse client // previously did this client-side; moving it server-side lets the - // directory's `display:` map apply to virtual entries the same - // way it applies to real ones. + // directory's `display:` map apply to virtual entries the same way + // it applies to real ones. result = append(result, virtualCanonicalFolders(ctx, decider, fsRoot, absDir, principal, baseURL, result, displayMap)...) - // Project-level virtual views: + // Tables register peers (ssr/mdl/rsk) at the project root: // - // Row rollups (ssr/mdl/rsk) — synthesize row entries (Writable - // bit per the canonical archive// chain) plus synthetic - // table.yaml/form.yaml entries so the tables tool's client-side - // walkServer finds the spec without a 404 round-trip. Spec bytes - // come from main.go IsDefaultSpec fallback; row reads go through - // handler.ServeVirtualViewRow which path-injects name/$party. + // mdl/ and rsk/ render an AGGREGATE table — one row per physical + // ///*.yaml across all parties. This REPLACES + // the physical party-subdir listing built above so the peer root + // shows the combined table (not a folder-nav of parties). Each + // row's URL is its real path; ACL is evaluated against the row's + // own chain; the $party column is injected on read (see the rollup + // row serve). // lists its rows normally. // - // Folder-nav (working/staging/reviewing) — synthesize one - // IsDir=true entry per party whose archive/// has - // non-empty content (in-flight filter). The browse client - // follows a click through to the virtual URL - // /// which the dispatcher 302s to the - // canonical archive///. - if vv := zddc.ResolveVirtualView(fsRoot, strings.TrimSuffix(baseURL, "/")); vv.Resolved && vv.Kind.IsRootKind() { - partyChains := make(map[string]zddc.PolicyChain) - chainFor := func(partyAbs string) zddc.PolicyChain { - if c, ok := partyChains[partyAbs]; ok { - return c - } - c, _ := zddc.EffectivePolicy(fsRoot, partyAbs) - partyChains[partyAbs] = c - return c - } - appendVirtualRow := func(syntheticName, partyAbs string) { - rowURL := baseURL + url.PathEscape(syntheticName) - chain := chainFor(partyAbs) - verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, rowURL) - if !verbs.Has(zddc.VerbR) { - return - } - result = append(result, listing.FileInfo{ - Name: syntheticName, - URL: rowURL, - IsDir: false, - Virtual: true, - Writable: verbs.Has(zddc.VerbW), - Verbs: verbs.String(), - }) - } - appendVirtualPartyDir := func(party, partyAbs string) { - dirURL := baseURL + url.PathEscape(party) + "/" - chain := chainFor(partyAbs) - verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, dirURL) - if !verbs.Has(zddc.VerbR) { - return - } - result = append(result, listing.FileInfo{ - Name: party + "/", - URL: dirURL, - IsDir: true, - Virtual: true, - Verbs: verbs.String(), - }) - } - - switch vv.Slot { - case "ssr": - parties, _ := zddc.ListSSRParties(fsRoot, vv.ProjectAbs) - for _, party := range parties { - partyAbs := filepath.Join(vv.ProjectAbs, "archive", party) - appendVirtualRow(party+".yaml", partyAbs) - } - case "mdl", "rsk": - rows, _ := zddc.ListRollupRows(fsRoot, vv.ProjectAbs, vv.Slot) + // ssr/ keeps its flat real-file listing (one ssr/.yaml per + // party = one row); only the spec entries are added. + // + // All three advertise synthetic table.yaml/form.yaml entries so the + // tables tool's client-side walkServer finds the spec without a 404 + // (spec bytes come from main.go's IsDefaultSpec fallback). + if segsURL := strings.Split(strings.Trim(baseURL, "/"), "/"); len(segsURL) == 2 && zddc.IsRowSlot(segsURL[1]) { + peer := segsURL[1] + projectAbs := filepath.Join(fsRoot, segsURL[0]) + if peer == "mdl" || peer == "rsk" { + rows, _ := zddc.ListRollupRows(projectAbs, peer) + agg := make([]listing.FileInfo, 0, len(rows)+2) + rowChains := make(map[string]zddc.PolicyChain) for _, row := range rows { - partyAbs := filepath.Join(vv.ProjectAbs, "archive", row.Party) - appendVirtualRow(row.SyntheticName, partyAbs) - } - case "working", "staging", "reviewing": - parties, _ := zddc.ListPartyDirsInSlot(fsRoot, vv.ProjectAbs, vv.Slot) - for _, party := range parties { - partyAbs := filepath.Join(vv.ProjectAbs, "archive", party) - appendVirtualPartyDir(party, partyAbs) - } - } - - // Row rollups carry synthetic spec entries so the tables tool - // can walkServer them. Folder-nav virtuals don't need spec - // files — they're just party listings rendered by browse. - // Verbs reflect actual cascade authority at each synthetic - // spec's URL so elevated admins see them as writable (they - // CAN materialise an override .zddc / spec by PUTting to - // the virtual path). Non-admins fall through to the default - // 'r' that the embedded baseline grants on the rollup view. - if zddc.IsRowSlot(vv.Slot) { - for _, spec := range []string{"table.yaml", "form.yaml"} { - specURL := baseURL + spec - verbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, specURL) + rowDir := filepath.Dir(row.Abs) + chain, ok := rowChains[rowDir] + if !ok { + chain, _ = zddc.EffectivePolicy(fsRoot, rowDir) + rowChains[rowDir] = chain + } + verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, row.RelURL) if !verbs.Has(zddc.VerbR) { continue } - result = append(result, listing.FileInfo{ - Name: spec, - URL: specURL, - IsDir: false, - Virtual: true, - Verbs: verbs.String(), + agg = append(agg, listing.FileInfo{ + Name: row.Filename, + URL: row.RelURL, + IsDir: false, + Writable: verbs.Has(zddc.VerbW), + Verbs: verbs.String(), }) } + result = agg + } + // Advertise the tables specs (skip any already present on disk). + have := make(map[string]bool, len(result)) + for _, fi := range result { + have[fi.Name] = true + } + for _, spec := range []string{"table.yaml", "form.yaml"} { + if have[spec] { + continue + } + specURL := baseURL + spec + verbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, specURL) + if !verbs.Has(zddc.VerbR) { + continue + } + result = append(result, listing.FileInfo{ + Name: spec, + URL: specURL, + IsDir: false, + Virtual: true, + Verbs: verbs.String(), + }) } } @@ -458,67 +412,6 @@ func virtualCanonicalFolders(ctx context.Context, decider policy.Decider, fsRoot return synth } -// virtualUserHomeEntry returns the synthetic / entry that -// should be appended to a per-party working/ listing, or (zero, false) -// when no synthetic entry applies. -// -// Under the canonical layout, per-user homes live at -// /archive//working// (depth-4 working slot -// inside the party folder). The synthetic entry fires when dirPath -// case-folds to /archive//working and the viewer has -// no real home folder yet. -// -// Conditions for the entry to fire: -// - dirPath case-folds to /archive//working at -// depth-4 of fsRoot -// - viewerEmail is non-empty -// - real does not already contain a directory entry that case-folds -// to viewerEmail (so a materialised home doesn't get duplicated) -func virtualUserHomeEntry(ctx context.Context, decider policy.Decider, fsRoot, dirPath string, principal zddc.Principal, baseURL string, real []listing.FileInfo) (listing.FileInfo, bool) { - if principal.Email == "" { - return listing.FileInfo{}, false - } - rel := strings.Trim(filepath.ToSlash(dirPath), "/") - parts := strings.Split(rel, "/") - if len(parts) != 4 || - !strings.EqualFold(parts[1], "archive") || - !strings.EqualFold(parts[3], "working") { - return listing.FileInfo{}, false - } - for _, fi := range real { - if !fi.IsDir { - continue - } - // fi.Name carries a trailing slash for dirs. - bare := strings.TrimSuffix(fi.Name, "/") - if strings.EqualFold(bare, principal.Email) { - return listing.FileInfo{}, false - } - } - // Compute verbs against the would-be home's own chain — the - // auto_own_fenced declaration in defaults.zddc.yaml means a real - // home grants the creator rwcda; the synthetic entry reports the - // same so client-side gating renders the "+ New" affordances - // immediately, before the first write materialises the folder. - homeAbs := filepath.Join(fsRoot, filepath.FromSlash(dirPath), principal.Email) - chain, err := zddc.EffectivePolicy(fsRoot, homeAbs) - if err != nil { - return listing.FileInfo{}, false - } - homeURL := baseURL + url.PathEscape(principal.Email) + "/" - verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, homeURL) - if !verbs.Has(zddc.VerbR) { - return listing.FileInfo{}, false - } - return listing.FileInfo{ - Name: principal.Email + "/", - URL: homeURL, - IsDir: true, - Virtual: true, - Verbs: verbs.String(), - }, true -} - // readDisplayMap parses dirAbs/.zddc and returns its Display map (or // nil when the file doesn't exist or has no display block). All keys // are case-folded to lowercase so lookupDisplay's case-insensitive diff --git a/zddc/internal/handler/accepthandler.go b/zddc/internal/handler/accepthandler.go index c449ee4..ac9e9ae 100644 --- a/zddc/internal/handler/accepthandler.go +++ b/zddc/internal/handler/accepthandler.go @@ -59,8 +59,8 @@ import ( const opAcceptTransmittal = "accept-transmittal" -// incomingURLPattern matches //archive//incoming//. -var incomingURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/incoming/([^/]+)/?$`) +// incomingURLPattern matches //incoming///. +var incomingURLPattern = regexp.MustCompile(`^/([^/]+)/incoming/([^/]+)/([^/]+)/?$`) type acceptRequest struct { ReceivedDate string `yaml:"received_date"` @@ -84,11 +84,17 @@ func serveAcceptTransmittal(cfg config.Config, w http.ResponseWriter, r *http.Re cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/" m := incomingURLPattern.FindStringSubmatch(cleanURL) if m == nil { - http.Error(w, "Bad Request — accept-transmittal must POST to //archive//incoming//", http.StatusBadRequest) + http.Error(w, "Bad Request — accept-transmittal must POST to //incoming///", http.StatusBadRequest) return } project, party, transmittalFolder := m[1], m[2], m[3] + // Filing requires the party to be registered (ssr/.yaml). + if !zddc.PartyRegistered(filepath.Join(cfg.Root, project), "ssr", party) { + http.Error(w, "Conflict — unknown party \""+party+"\"; register it in ssr/ first", http.StatusConflict) + return + } + date, tracking, _, _, ok := zddc.ParseTransmittalFolder(transmittalFolder) if !ok { http.Error(w, "Bad Request — folder name does not conform to ZDDC transmittal grammar (expected YYYY-MM-DD_ () - )", http.StatusBadRequest) @@ -115,7 +121,7 @@ func serveAcceptTransmittal(cfg config.Config, w http.ResponseWriter, r *http.Re } } - incomingAbs := filepath.Join(cfg.Root, project, "archive", party, "incoming", transmittalFolder) + incomingAbs := filepath.Join(cfg.Root, project, "incoming", party, transmittalFolder) receivedAbs := filepath.Join(cfg.Root, project, "archive", party, "received", tracking) receivedURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/" diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index 50de8f7..e215300 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -299,23 +299,20 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) { http.Error(w, "PUT must target a file, not a directory", http.StatusBadRequest) return } - - // Virtual project-level table views — SSR / MDL rollup / RSK - // rollup. The PUT URL lives in <project>/{ssr,mdl,rsk}/...; the - // underlying bytes belong inside <project>/archive/<party>/. We - // rewrite abs + cleanURL to the canonical path so the rest of - // this function (ACL gate, ETag, audit, conversion-cache purge) - // operates on the real file location. - // - // SSR row PUTs land at archive/<party>/ssr.yaml; MDL/RSK rollup - // row PUTs land at archive/<party>/<slot>/<file>.yaml. Same - // shape as the virtual-received rewrite below. - if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() { - abs = vv.CanonicalAbs - cleanURL = vv.CanonicalURL - w.Header().Set("X-ZDDC-Resolved-Path", cleanURL) + // A PUT that would introduce a new party folder under a party_source + // peer (e.g. working/<newparty>/file, or filing into + // archive/<newparty>/received/) requires the party to be registered. + if rejected, why, _ := partySourceGate(cfg.Root, abs); rejected { + http.Error(w, why, http.StatusConflict) + return } + // Register rows (ssr/<party>.yaml, mdl|rsk/<party>/<file>.yaml) are + // real files in the flat-peer layout — a PUT targets them directly, + // no virtual→canonical rewrite. The path-derived $party/name column + // is injected only on read (ServeInjectedRow) and stripped by the + // client before submit. + // Virtual received/ rewrite. When the PUT targets a file under the // synthetic <workflow>/received/<file> URL, the canonical record is // WORM — we can't write there. Convention: treat the drop as a @@ -510,20 +507,10 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request) return } - // Virtual project-level table views. SSR row deletes are refused - // (would orphan the party folder and its mdl/rsk contents) — use - // the archive view to delete a party. MDL/RSK rollup row deletes - // pass through to the canonical archive/<party>/<slot>/<file>.yaml - // path with the normal ACL gate. - if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() { - if vv.Kind == zddc.VirtualViewSSRRow { - http.Error(w, "Method Not Allowed — delete the party folder via the archive view, not the SSR table", http.StatusMethodNotAllowed) - return - } - abs = vv.CanonicalAbs - cleanURL = vv.CanonicalURL - w.Header().Set("X-ZDDC-Resolved-Path", cleanURL) - } + // Register rows are real files — a DELETE targets them directly with + // the normal ACL gate. (Deleting an ssr/<party>.yaml de-registers the + // party; the ssr/ ACL grants delete only to admins by default so a + // party with archived records can't be orphaned.) info, err := os.Stat(abs) if err != nil { @@ -612,6 +599,12 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) { http.Error(w, "MOVE destination must be a file path", http.StatusBadRequest) return } + // A move whose destination introduces a new party folder under a + // party_source peer requires the party to be registered. + if rejected, why, _ := partySourceGate(cfg.Root, dstAbs); rejected { + http.Error(w, "destination: "+why, http.StatusConflict) + return + } // Resolve canonical-folder casing on src + dst (no side effects). if r2, err := zddc.ResolveCanonicalPath(cfg.Root, srcAbs); err == nil { @@ -725,19 +718,14 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) { // Project-root mkdir policy: the only physical child allowed // directly under <project>/ is `archive` (plus _/.-prefixed // system names). Mkdir of any other name — including the six - // virtual aggregator names (ssr/mdl/rsk/working/staging/reviewing) - // — is rejected with 409, because the virtual would shadow any - // physical folder created at the same URL. + // non-peer name — is rejected with 409. if rejected, why := rejectProjectRootMkdir(cfg.Root, abs); rejected { http.Error(w, why, http.StatusConflict) return } - // Mkdir INSIDE a virtual aggregator (working/staging/reviewing/…) has - // no physical home — the content is party-scoped. 409 with a pointer - // at archive/<party>/<slot>/ rather than silently creating an - // unreachable shadow folder. (Browse's picker targets the archive/ - // path directly, so this is the bypass fallback.) - if rejected, why := rejectProjectAggregatorMkdir(cfg.Root, abs); rejected { + // A new party folder under a party_source peer requires the party to + // be registered (ssr/<party>.yaml exists); else 409. + if rejected, why := rejectUnregisteredPartyMkdir(cfg.Root, abs); rejected { http.Error(w, why, http.StatusConflict) return } @@ -852,48 +840,54 @@ func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) { return false, "" } name := parts[1] - if name == "archive" { - return false, "" - } if strings.HasPrefix(name, "_") || strings.HasPrefix(name, ".") { // System-reserved namespace; allowed. return false, "" } - lower := strings.ToLower(name) - if zddc.IsVirtualAggregatorSlot(lower) { - return true, "Conflict — " + lower + "/ is a project-level virtual aggregator and cannot be created as a physical folder. Files of this kind live under archive/<party>/" + lower + "/." + if zddc.IsProjectPeer(name) { + return false, "" } - return true, "Conflict — only archive/ and system-reserved (_/. prefix) folders may be created directly under a project. Files belong inside archive/<party>/..." + return true, "Conflict — only the canonical peers (archive, incoming, working, staging, reviewing, mdl, rsk, ssr) and system-reserved (_/. prefix) folders may be created directly under a project." } -// rejectProjectAggregatorMkdir reports whether a mkdir lands INSIDE a -// project-level virtual aggregator — <project>/{ssr,mdl,rsk,working, -// staging,reviewing}/<name>[/...] (depth 3+). Those slots aggregate -// per-party content; a folder created there has no physical home. The -// real location is archive/<party>/<slot>/<name>, so we 409 with a -// message pointing at the party-scoped path. (rejectProjectRootMkdir -// handles the depth-2 case — the aggregator name itself.) The browse -// "New folder" picker resolves the party client-side and targets the -// archive/ path directly, so a user never trips this in normal use; it -// is the clean fallback for a direct/scripted mkdir that bypassed it. -func rejectProjectAggregatorMkdir(fsRoot, abs string) (bool, string) { +// rejectUnregisteredPartyMkdir enforces the party_source cascade key: a +// new <party> folder under a peer that declares party_source (every peer +// except ssr/) may be created only if the party is registered — i.e. the +// registry entry exists (ssr/<party>.yaml). Applies at the party-segment +// depth and below (<project>/<peer>/<party>[/...]). Registration itself +// (creating ssr/<party>.yaml) is not gated — ssr/ sets no party_source. +func rejectUnregisteredPartyMkdir(fsRoot, abs string) (bool, string) { + reject, msg, _ := partySourceGate(fsRoot, abs) + return reject, msg +} + +// partySourceGate is the shared party_source check used by mkdir, PUT +// (create), and move (dst). It returns reject=true (+ a 409 message) +// when abs would introduce a <party> segment under a party_source peer +// for a party that isn't registered. The third return is the resolved +// party name (for callers that want to log it). +func partySourceGate(fsRoot, abs string) (reject bool, msg, party string) { rel, err := filepath.Rel(fsRoot, abs) if err != nil { - return false, "" + return false, "", "" } rel = filepath.ToSlash(rel) if rel == "." || strings.HasPrefix(rel, "../") { - return false, "" + return false, "", "" } parts := strings.Split(rel, "/") if len(parts) < 3 { - return false, "" // depth-2 (the slot itself) is rejectProjectRootMkdir's job + return false, "", "" // <project>/<peer> — no party segment yet } - if slot := strings.ToLower(parts[1]); zddc.IsVirtualAggregatorSlot(slot) { - return true, "Conflict — " + slot + "/ is a project-level virtual aggregator; folders here belong to a party. " + - "Create it under archive/<party>/" + slot + "/ — browse's \"New folder\" picker prompts you for the party." + project, peer, p := parts[0], parts[1], parts[2] + source := zddc.PartySourceAt(fsRoot, filepath.Join(fsRoot, project, peer)) + if source == "" { + return false, "", "" // peer does no party gating (e.g. ssr/) } - return false, "" + if zddc.PartyRegistered(filepath.Join(fsRoot, project), source, p) { + return false, "", p + } + return true, "Conflict — unknown party \"" + p + "\". Register it first by creating " + source + "/" + p + ".yaml (the SSR form).", p } // auditFile emits a structured log line for each file API operation. diff --git a/zddc/internal/handler/formhandler.go b/zddc/internal/handler/formhandler.go index 1aa6624..81729b1 100644 --- a/zddc/internal/handler/formhandler.go +++ b/zddc/internal/handler/formhandler.go @@ -203,36 +203,11 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest { if strings.HasSuffix(underlying, ".yaml") { // /<dir>/<id>.yaml.html — re-edit / update. Spec lives in the - // SAME directory as the row file (<dir>/form.yaml) UNLESS the - // URL maps to one of the project-level virtual views, in which - // case the canonical SpecPath / DataPath are inside the per- - // party archive folder. ResolveVirtualView handles the rewrite; - // SubmitURL stays as the virtual URL so the form POSTs back to - // the same endpoint (which re-resolves to the same canonical - // paths on the second pass). - if vv := zddc.ResolveVirtualView(fsRoot, underlying); vv.Resolved && vv.Kind.IsRowKind() { - var specPath string - switch vv.Kind { - case zddc.VirtualViewSSRRow: - specPath = vv.SchemaAbs - case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow: - specPath = filepath.Join(vv.PartyArchive, vv.Slot, "form.yaml") - } - if !specEligible(specPath) { - return nil - } - kind := "render-edit" - if method == http.MethodPost { - kind = "update" - } - return &FormRequest{ - Kind: kind, - SpecPath: specPath, - DataPath: vv.CanonicalAbs, - SubmitURL: urlPath, - } - } - + // SAME directory as the row file (<dir>/form.yaml). Register rows + // are real files now (ssr/<party>.yaml, mdl|rsk/<party>/<file>.yaml), + // so the in-dir rule resolves them directly — the spec falls back + // to the embedded default via IsDefaultSpecAbs when no on-disk + // form.yaml exists. dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/"))) dataAbs := filepath.Join(fsRoot, dataRel) if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot { diff --git a/zddc/internal/handler/planreview.go b/zddc/internal/handler/planreview.go index 39b9372..98868e3 100644 --- a/zddc/internal/handler/planreview.go +++ b/zddc/internal/handler/planreview.go @@ -60,10 +60,10 @@ const opPlanReview = "plan-review" // planReviewRequest is the YAML body the browse client POSTs. type planReviewRequest struct { - ReviewLead string `yaml:"review_lead"` - Approver string `yaml:"approver"` - PlanReviewCompleteDate string `yaml:"plan_review_complete_date"` - PlanResponseDate string `yaml:"plan_response_date"` + ReviewLead string `yaml:"review_lead"` + Approver string `yaml:"approver"` + PlanReviewCompleteDate string `yaml:"plan_review_complete_date"` + PlanResponseDate string `yaml:"plan_response_date"` } // planReviewResponse is the JSON returned to the client. @@ -143,12 +143,12 @@ func executePlanReview(cfg config.Config, r *http.Request, project, party, track receivedAbs := filepath.Join(cfg.Root, project, filepath.FromSlash(receivedRel)) cleanURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/" - // Hardcoded path convention. Every project has exactly one - // reviewing/ and one staging/ slot per party at fixed offsets; - // the composite endpoint scaffolds inside the originating party's - // slots. - reviewingRoot := filepath.Join(cfg.Root, project, "archive", party, "reviewing") - stagingRoot := filepath.Join(cfg.Root, project, "archive", party, "staging") + // Hardcoded path convention. The composite endpoint scaffolds a + // submittal folder inside the top-level reviewing/<party>/ and + // staging/<party>/ peers; each carries received_path back to the + // canonical record in archive/<party>/received/<tracking>. + reviewingRoot := filepath.Join(cfg.Root, project, "reviewing", party) + stagingRoot := filepath.Join(cfg.Root, project, "staging", party) // Pre-flight authorisation. No ACL exception — we use existing // cascade grants: diff --git a/zddc/internal/handler/ssrhandler.go b/zddc/internal/handler/ssrhandler.go index 1bd6a98..615ed36 100644 --- a/zddc/internal/handler/ssrhandler.go +++ b/zddc/internal/handler/ssrhandler.go @@ -98,67 +98,37 @@ func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWrit return } - partyAbs := filepath.Join(cfg.Root, req.Project, "archive", name) - if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) { + rowURL := "/" + req.Project + "/ssr/" + name + ".yaml" + yamlAbs := filepath.Join(cfg.Root, req.Project, "ssr", name+".yaml") + if !strings.HasPrefix(yamlAbs, cfg.Root+string(filepath.Separator)) { http.Error(w, "Not Found", http.StatusNotFound) return } - partyURL := "/" + req.Project + "/archive/" + name + "/" - rowURL := "/" + req.Project + "/ssr/" + name + ".yaml" - // ACL gate: create at <project>/archive/<name>/. authorizeAction walks - // up to the closest existing ancestor for the chain — typically - // <project>/archive/, where document_controller carries rwc per the - // project-level cascade. - if !authorizeAction(cfg, w, r, partyAbs, partyURL, policy.ActionCreate) { + // ACL gate: create the registry row at <project>/ssr/<name>.yaml. + // authorizeAction walks to the closest existing ancestor (ssr/ or the + // project), where document_controller carries rwc per the cascade. + // Creating this file IS registering the party. + if !authorizeAction(cfg, w, r, yamlAbs, rowURL, policy.ActionCreate) { return } - // Refuse to clobber an existing party folder. The SSR view shows - // any folder under archive/*/; if one with this name exists, the - // user should edit that row instead of creating a duplicate. - if info, err := os.Stat(partyAbs); err == nil { - if info.IsDir() { - http.Error(w, "Conflict — a party folder with that name already exists", http.StatusConflict) - return - } - http.Error(w, "Conflict — a file exists at this path", http.StatusConflict) + // Refuse to clobber an existing registration — edit that row instead. + if _, err := os.Stat(yamlAbs); err == nil { + http.Error(w, "Conflict — party \""+name+"\" is already registered", http.StatusConflict) return } else if !errors.Is(err, os.ErrNotExist) { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - // Materialize canonical ancestors (<project>/archive/) with auto-own - // seeding before creating the party folder itself. Mirrors what the - // generic file-API mkdir does at fileapi.go:629-634. - yamlAbs := filepath.Join(partyAbs, "ssr.yaml") + // Materialize the ssr/ ancestor before writing the registry row. if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, yamlAbs, email, 0o755); err != nil { slog.Warn("ssr-create: ensure canonical ancestors", "path", yamlAbs, "err", err) } - if err := os.MkdirAll(partyAbs, 0o755); err != nil { - auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, 0, err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - // Auto-own .zddc on the new party folder. archive/*/ is declared - // auto_own in defaults.zddc.yaml, so the unfenced creator grant - // fires here exactly as it would for a manual mkdir. - if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) { - roles := zddc.AutoOwnRolesAt(cfg.Root, partyAbs) - var werr error - if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) { - werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email, roles) - } else { - werr = zddc.WriteAutoOwnZddc(partyAbs, email, roles) - } - if werr != nil { - slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr) - } - } - // Drop the path-derived `name` field — it's the folder name, not - // row data. The dispatcher re-injects it on read. + // Drop the path-derived `name` field — it's the filename, not row + // data. The dispatcher re-injects it on read. delete(dataMap, "name") yamlBytes, err := yaml.Marshal(dataMap) @@ -252,27 +222,26 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW return } - partyAbs := filepath.Join(cfg.Root, req.Project, "archive", party) - if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - if info, err := os.Stat(partyAbs); err != nil || !info.IsDir() { + // The party must be registered (ssr/<party>.yaml exists) before rows + // can be filed for it. + if !zddc.PartyRegistered(filepath.Join(cfg.Root, req.Project), "ssr", party) { writeValidationErrors(w, []jsonschema.Error{{ Path: "/party", - Message: "party folder does not exist — create it via the SSR view first", + Message: "unknown party — register it via the SSR view first", }}) return } - slotAbs := filepath.Join(partyAbs, req.Slot) - slotURL := "/" + req.Project + "/archive/" + party + "/" + req.Slot + "/" - rowDirURL := slotURL // The slot folder where the new row lands. - _ = rowDirURL // kept for clarity; ACL chain is gated below. + slotAbs := filepath.Join(cfg.Root, req.Project, req.Slot, party) + if !strings.HasPrefix(slotAbs, cfg.Root+string(filepath.Separator)) { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + slotURL := "/" + req.Project + "/" + req.Slot + "/" + party + "/" - // ACL gate: create at <project>/archive/<party>/<slot>/. authorizeAction - // walks up to the closest existing ancestor (typically <party>/), where - // the auto-own .zddc grants the party owner rwcd. + // ACL gate: create at <project>/<peer>/<party>/. authorizeAction walks + // up to the closest existing ancestor (the peer or project), where + // document_controller carries rwcd per the cascade. if !authorizeAction(cfg, w, r, slotAbs, slotURL, policy.ActionCreate) { return } @@ -351,7 +320,7 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW // Route through WriteWithHistory for audit stamping. The // filename_format check inside WriteWithHistory passes because // the path we constructed above used the same composition. - res, verrs, herr := WriteWithHistory(cfg, target, "/"+req.Project+"/"+req.Slot+"/"+party+"__"+fname, yamlBytes, email) + res, verrs, herr := WriteWithHistory(cfg, target, "/"+req.Project+"/"+req.Slot+"/"+party+"/"+fname, yamlBytes, email) if herr != nil { auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, len(yamlBytes), herr) http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError) @@ -363,7 +332,7 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW } finalBody := res.FinalBody - rowURL := "/" + req.Project + "/" + req.Slot + "/" + party + "__" + fname + rowURL := "/" + req.Project + "/" + req.Slot + "/" + party + "/" + fname w.Header().Set("Location", rowURL) w.Header().Set("Content-Type", "application/json") w.Header().Set("X-ZDDC-Source", "rollup-create") @@ -398,8 +367,8 @@ func serveSSRRename(cfg config.Config, w http.ResponseWriter, r *http.Request) { return } - src := zddc.ResolveVirtualView(cfg.Root, r.URL.Path) - if !src.Resolved || src.Kind != zddc.VirtualViewSSRRow { + srcProject, srcParty, ok := parseSSRRowURL(r.URL.Path) + if !ok { http.Error(w, "Bad Request — ssr-rename source must be /<project>/ssr/<party>.yaml", http.StatusBadRequest) return } @@ -412,69 +381,81 @@ func serveSSRRename(cfg config.Config, w http.ResponseWriter, r *http.Request) { if dec, err := url.PathUnescape(dstHeader); err == nil { dstHeader = dec } - dst := zddc.ResolveVirtualView(cfg.Root, dstHeader) - if !dst.Resolved || dst.Kind != zddc.VirtualViewSSRRow { + dstProject, dstParty, ok := parseSSRRowURL(dstHeader) + if !ok { http.Error(w, "Bad Request — ssr-rename destination must be /<project>/ssr/<party>.yaml", http.StatusBadRequest) return } - if dst.Project != src.Project { + if dstProject != srcProject { http.Error(w, "Bad Request — ssr-rename cannot cross projects", http.StatusBadRequest) return } - if dst.Party == src.Party { + if dstParty == srcParty { http.Error(w, "Bad Request — destination is the same as source", http.StatusBadRequest) return } - // Source party folder must exist. - srcArchive := src.PartyArchive - if info, err := os.Stat(srcArchive); err != nil || !info.IsDir() { - if err != nil && !errors.Is(err, os.ErrNotExist) { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } + srcAbs := filepath.Join(cfg.Root, srcProject, "ssr", srcParty+".yaml") + dstAbs := filepath.Join(cfg.Root, dstProject, "ssr", dstParty+".yaml") + srcURL := "/" + srcProject + "/ssr/" + srcParty + ".yaml" + dstURL := "/" + dstProject + "/ssr/" + dstParty + ".yaml" + + // Source registry row must exist; destination must not. + if info, err := os.Stat(srcAbs); err != nil || info.IsDir() { http.Error(w, "Not Found", http.StatusNotFound) return } - // Destination must not exist. - dstArchive := dst.PartyArchive - if _, err := os.Stat(dstArchive); err == nil { - http.Error(w, "Conflict — destination party folder already exists", http.StatusConflict) + if _, err := os.Stat(dstAbs); err == nil { + http.Error(w, "Conflict — party \""+dstParty+"\" is already registered", http.StatusConflict) return } else if !errors.Is(err, os.ErrNotExist) { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - // ACL: write on src archive, create on dst archive. URLs include - // the trailing slash convention used elsewhere for directory ops. - srcArchiveURL := "/" + src.Project + "/archive/" + src.Party + "/" - dstArchiveURL := "/" + dst.Project + "/archive/" + dst.Party + "/" - if !authorizeAction(cfg, w, r, srcArchive, srcArchiveURL, policy.ActionWrite) { + // ACL: write on the old registry row, create on the new one (ssr/ + // grants document_controller rwc). + if !authorizeAction(cfg, w, r, srcAbs, srcURL, policy.ActionWrite) { return } - if !authorizeAction(cfg, w, r, dstArchive, dstArchiveURL, policy.ActionCreate) { + if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) { return } - // Optional If-Match against the source ssr.yaml etag. - if !checkIfMatch(w, r, src.CanonicalAbs) { + if !checkIfMatch(w, r, srcAbs) { return } - if err := os.Rename(srcArchive, dstArchive); err != nil { + // Registry-only rename: moves the ssr/<party>.yaml row. This does NOT + // move the party's workspace/record folders across the other peers + // (archive/, mdl/, working/, …); those keep the old name until moved + // separately. Cross-peer party rename is a deliberate later/admin op. + if err := os.Rename(srcAbs, dstAbs); err != nil { auditFile(r, "ssr-rename", r.URL.Path, http.StatusInternalServerError, 0, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - newURL := "/" + dst.Project + "/ssr/" + dst.Party + ".yaml" - w.Header().Set("Location", newURL) - w.Header().Set("X-ZDDC-Destination", newURL) + w.Header().Set("Location", dstURL) + w.Header().Set("X-ZDDC-Destination", dstURL) w.Header().Set("X-ZDDC-Source", "ssr-rename") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - resp, _ := json.Marshal(map[string]string{"location": newURL}) + resp, _ := json.Marshal(map[string]string{"location": dstURL}) _, _ = w.Write(resp) auditFile(r, "ssr-rename", r.URL.Path, http.StatusOK, 0, nil) } + +// parseSSRRowURL parses /<project>/ssr/<party>.yaml into (project, party). +func parseSSRRowURL(urlPath string) (project, party string, ok bool) { + parts := strings.Split(strings.Trim(urlPath, "/"), "/") + if len(parts) != 3 || parts[1] != "ssr" || !strings.HasSuffix(parts[2], ".yaml") { + return "", "", false + } + project = parts[0] + party = strings.TrimSuffix(parts[2], ".yaml") + if project == "" || !zddc.ValidPartyName(party) { + return "", "", false + } + return project, party, true +} diff --git a/zddc/internal/handler/tablehandler.go b/zddc/internal/handler/tablehandler.go index e657f0f..7129420 100644 --- a/zddc/internal/handler/tablehandler.go +++ b/zddc/internal/handler/tablehandler.go @@ -113,12 +113,11 @@ func DefaultProjectRskFormYAML() []byte { return embeddedDefaultProjectRskForm } // default-spec virtual files served when no operator file exists on // disk. Recognized URL shapes: // -// <project>/archive/<party>/mdl/{table.yaml, form.yaml} -// <project>/archive/<party>/rsk/{table.yaml, form.yaml} -// <project>/archive/<party>/ssr.form.yaml -// <project>/ssr/{table.yaml, form.yaml} -// <project>/mdl/{table.yaml, form.yaml} -// <project>/rsk/{table.yaml, form.yaml} +// <project>/mdl/{table.yaml, form.yaml} aggregate (with $party) +// <project>/rsk/{table.yaml, form.yaml} aggregate (with $party) +// <project>/ssr/{table.yaml, form.yaml} registry table +// <project>/mdl/<party>/{table.yaml, form.yaml} per-party +// <project>/rsk/<party>/{table.yaml, form.yaml} per-party // // Returns embedded bytes + true when the fallback should fire; nil + // false when an operator file exists at that path or the URL is not @@ -163,14 +162,13 @@ func IsDefaultSpecAbs(fsRoot, absPath string) ([]byte, bool) { func classifyDefaultSpec(rel string) []byte { parts := strings.Split(rel, "/") switch len(parts) { - case 5: - // <project>/archive/<party>/<slot>/<file> - if !strings.EqualFold(parts[1], "archive") { - return nil - } - slot := strings.ToLower(parts[3]) - file := strings.ToLower(parts[4]) - switch slot { + case 4: + // <project>/<peer>/<party>/<file> — per-party register specs + // (mdl/<party>/, rsk/<party>/). The single-party table/form, + // no $party column. + peer := strings.ToLower(parts[1]) + file := strings.ToLower(parts[3]) + switch peer { case "mdl": switch file { case "table.yaml": @@ -186,19 +184,13 @@ func classifyDefaultSpec(rel string) []byte { return embeddedDefaultRskForm } } - case 4: - // <project>/archive/<party>/<file> — only ssr.form.yaml is virtual. - if !strings.EqualFold(parts[1], "archive") { - return nil - } - if strings.EqualFold(parts[3], "ssr.form.yaml") { - return embeddedDefaultSsrForm - } case 3: - // <project>/<slot>/<file> — project-level virtual specs. - slot := strings.ToLower(parts[1]) + // <project>/<peer>/<file> — peer-root specs. ssr/ is a flat + // register; mdl/ and rsk/ are the cross-party AGGREGATE tables + // (the project-level spec carries the $party column). + peer := strings.ToLower(parts[1]) file := strings.ToLower(parts[2]) - switch slot { + switch peer { case "ssr": switch file { case "table.yaml": diff --git a/zddc/internal/handler/virtualviewhandler.go b/zddc/internal/handler/virtualviewhandler.go index 8ffb0be..1469ef7 100644 --- a/zddc/internal/handler/virtualviewhandler.go +++ b/zddc/internal/handler/virtualviewhandler.go @@ -1,31 +1,23 @@ -// Package handler — virtualviewhandler.go: GET dispatch for SSR row + -// MDL/RSK rollup row URLs. +// Package handler — virtualviewhandler.go: GET dispatch for the +// aggregate register rows (mdl/<party>/*.yaml, rsk/<party>/*.yaml, +// ssr/<party>.yaml). // -// These URLs live in the project-level virtual folders (<project>/ssr, -// <project>/mdl, <project>/rsk) and rewrite to canonical files inside -// <project>/archive/<party>/. The bytes returned to the client are -// augmented with a single path-derived field that the canonical file -// doesn't carry: +// In the flat-peer layout these are REAL files at their own paths; the +// only thing the on-disk bytes lack is the path-derived source column +// the aggregate table renders: // -// - SSR rows get `name: <party>` so the table renderer has a column -// to sort on and the form edit pre-fills the party name. (Identity -// of an SSR row is the party folder name, so the field is named -// plainly rather than sigil-prefixed.) -// - MDL / RSK rollup rows get `$party: <party>` so the rollup table -// can show which package each row came from. The `$` sigil marks -// the field as system-synthesised: tables tool renders it read- -// only and the form client strips it before submit, so a user- -// defined `party` field on a deliverable row never collides with -// the synthetic source-party column. +// - MDL / RSK rows get `$party: <party>` so the cross-party rollup +// can show which party each row came from. The `$` sigil marks the +// field system-synthesised: the tables tool renders it read-only and +// the form client strips it before submit, so a user-defined `party` +// field never collides with the synthetic source-party column. +// - SSR rows get `name: <party>` (the party = the filename) so the +// register table has an identity column to sort on and the form edit +// pre-fills the party name. // -// Both fields are stripped before write-back (SSR via serveFormCreateSSR -// strip; MDL/RSK rollup writes go through the generic serveFormUpdate, -// where the path-derived `$party:` is rejected by `additionalProperties: -// false` in the underlying schema — so the client must strip it on -// submit, which the tables/form JS already does for path-derived -// fields). -// -// Listings: see fs/tree.go. +// Both fields are path-derived and stripped before write-back by the +// tables/form JS (the schema's additionalProperties:false also rejects +// `$party` on submit). Listings: see fs/tree.go. package handler @@ -34,70 +26,45 @@ import ( "os" "strconv" - "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "gopkg.in/yaml.v3" ) -// ServeVirtualViewRow serves a GET (or HEAD) for one of the virtual -// row URLs. Caller is expected to have already evaluated ACL against -// vv.PartyArchive's chain. -// -// For SSR rows: returns the canonical archive/<party>/ssr.yaml bytes -// with `name: <party>` injected. If no canonical file exists yet, -// returns `name: <party>\n` (an otherwise-empty row) — the SSR view -// shows every party folder whether or not metadata has been written. -// -// For MDL / RSK rollup rows: returns the canonical bytes with -// `party: <party>` injected. If the canonical file doesn't exist -// (shouldn't happen — the listing only surfaces real files) returns -// 404. -func ServeVirtualViewRow(w http.ResponseWriter, r *http.Request, vv zddc.VirtualViewResolution) { - if !vv.Resolved || !vv.Kind.IsRowKind() { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - raw, err := os.ReadFile(vv.CanonicalAbs) +// ServeInjectedRow serves a GET/HEAD for a real register row file with a +// single path-derived field injected (field=value). Used by dispatch for +// the aggregate register rows; ACL is evaluated by the caller against the +// file's own chain. Returns 404 if the file doesn't exist. +func ServeInjectedRow(w http.ResponseWriter, r *http.Request, abs, field, value string) { + raw, err := os.ReadFile(abs) if err != nil { - if !os.IsNotExist(err) { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - // File doesn't exist yet. - if vv.Kind != zddc.VirtualViewSSRRow { + if os.IsNotExist(err) { http.NotFound(w, r) return } - raw = nil + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return } var data map[string]any if len(raw) > 0 { if err := yaml.Unmarshal(raw, &data); err != nil { - http.Error(w, "parse canonical yaml: "+err.Error(), http.StatusInternalServerError) + http.Error(w, "parse row yaml: "+err.Error(), http.StatusInternalServerError) return } } if data == nil { data = make(map[string]any) } - switch vv.Kind { - case zddc.VirtualViewSSRRow: - data["name"] = vv.Party - case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow: - data["$party"] = vv.Party - } + data[field] = value out, err := yaml.Marshal(data) if err != nil { - http.Error(w, "marshal virtual row: "+err.Error(), http.StatusInternalServerError) + http.Error(w, "marshal row: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/yaml; charset=utf-8") w.Header().Set("Cache-Control", "no-store") - w.Header().Set("X-ZDDC-Source", "virtual-view-row") - w.Header().Set("X-ZDDC-Resolved-Path", vv.CanonicalURL) + w.Header().Set("X-ZDDC-Source", "register-row") w.Header().Set("Content-Length", strconv.Itoa(len(out))) if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) diff --git a/zddc/internal/zddc/cascade.go b/zddc/internal/zddc/cascade.go index 7332859..2d362f7 100644 --- a/zddc/internal/zddc/cascade.go +++ b/zddc/internal/zddc/cascade.go @@ -406,6 +406,7 @@ func nonZeroZddcFields(zf ZddcFile) []string { add("auto_own_fenced", zf.AutoOwnFenced != nil) add("virtual", zf.Virtual != nil) add("drop_target", zf.DropTarget != nil) + add("party_source", zf.PartySource != "") add("history", zf.History != nil) add("history_globs", len(zf.HistoryGlobs) > 0) add("worm", zf.Worm != nil) diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 42a2389..079e932 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -23,72 +23,43 @@ acl: # ── Standard roles ───────────────────────────────────────────────────────── # # Three roles ship empty (no members) — a fresh deployment grants -# nothing until an operator populates them. They're referenced by the -# project-scoped grants in paths: below. +# nothing until an operator populates them. Membership UNIONS across +# the cascade; use `reset: true` at a subtree to start fresh. # -# Role membership UNIONS across the cascade: an on-disk .zddc that -# defines `project_team` again with one extra member ADDS that member -# to the inherited role. To start fresh at a subtree (e.g. a project -# wanting its own team independent of a deployment-wide default), use -# `reset: true` on the role at that level — ancestor definitions above -# the reset are then excluded. +# document_controller — owns the committed record and the party +# registry. They: +# - register parties: a party EXISTS iff ssr/<party>.yaml exists, +# and the DC creates it (rwc at ssr/). This is the single +# source of truth for party existence. +# - file write-once into the WORM archive: read + create at +# archive/<party>/received and issued via the worm: list (the +# WORM mask strips w/d/a; create survives only for listed +# principals). archive/ also grants rwc so the DC can create +# party record dirs. +# - rwcda across the live workspaces (incoming/working/staging/ +# reviewing), restated per-peer so a DC matched by the +# project_team wildcard keeps full authority via within-level +# union. +# NOT a subtree-admin anywhere — no admins: entry. DCs cannot +# bypass WORM (only worm-create); admin elevation is reserved for +# the root admins: list (the human escape hatch for mis-filed +# documents or recovery). # -# document_controller — the people who file into -# archive/<party>/received/ and issued/ (WORM zones). They get: -# - rwcda at every archive/<party>/ via the role grant written -# into each party's auto-own .zddc (auto_own_roles below). -# Cascade carries rwcda down to descendants by default. -# - read+write-once-create at received/issued via the worm: -# lists (the WORM mask strips w/d/a even though the role -# grant supplies rwcda at the party level above). -# - rwcd explicit at incoming/ and staging/ (the QC and -# transmittal-out workflows need `d` to move files between -# slots; the explicit grants shadow the inherited rwcda -# to make the intent visible). -# - rwc at archive/ so they can create party subfolders. +# project_team — everyone working on a project. Read across the +# project, with a one-way ratchet through the live workspaces: +# working/ cr create + read; auto_own gives the creator +# rwcda inside the party folder they make +# staging/ cr drop + read, no modify after the drop +# reviewing/ cr create + read review iterations +# incoming/ r counterparty's drop zone (observe) +# archive/ r the committed record (received/issued), WORM +# ssr/mdl/rsk r registry + registers (the DC maintains them) +# Each handoff drops the role's modify rights for the previous +# stage. # -# NOT a subtree-admin anywhere. There is no `admins:` entry for -# the role — DCs cannot bypass WORM (only worm-create via the -# list) and cannot reach inside fenced working homes. Admin -# elevation is reserved for the root admins: list (the human -# escape hatch for mis-filed documents or recovery). -# -# Plan-Review approval is part of this role by design — there is -# no separate `approver` role; two-person sign-off, when needed, -# is expressed via per-folder `.zddc` overrides rather than -# baked-in roles. -# -# project_team — everyone working on a project. Read-only across -# the project by default, with a one-way ratchet through the -# in-flight slots: -# -# working/ cr — create + read; the auto_own_fenced child -# gives the creator rwcda in their own home, -# fenced from siblings -# staging/ cr — drop + read, no modify (after drop, the -# doc-controller is the only one who can -# change it) -# reviewing/ cr — create + read; auto_own (unfenced) gives -# creator rwcda in their iteration folder, -# siblings see it via project-level :r -# received/ r — WORM zone; only document_controller can -# file (and even they need elevation to edit) -# issued/ r — WORM zone; published, immutable -# incoming/ r — counterparty's drop zone (project_team -# observers it, doc_controller QCs it) -# -# "Each handoff drops the role's modify rights for the previous -# slot." That's the model — project_team works freely in -# working/, commits to staging/, and from there the doc- -# controller takes over. -# -# observer — pure read-only across the project. Like project_team -# but with no auto-own home: an observer who somehow created a -# working/<email>/ would still own it via auto-own (the mechanism -# is path-keyed, not role-keyed), but since observer lacks `c` -# anywhere, the situation doesn't arise in practice. Intended for -# auditors, regulators, and external read-only viewers who must -# not contribute content. +# observer — pure read-only across the project; no create anywhere. +# Intended for auditors, regulators, and external read-only +# viewers who must not contribute content. roles: document_controller: members: [] @@ -99,481 +70,243 @@ roles: # Universal tool baseline. archive (record browser), browse (file # tree, hosts the in-place markdown editor), and landing (project -# picker) work everywhere. Each canonical folder below adds its own -# context-specific tools (transmittal in staging/, etc.). The cascade -# unions available_tools across all levels — leaf restrictions don't -# drop ancestor entries — so this baseline propagates to every -# descendant. +# picker) work everywhere. Each peer below adds its own tools +# (transmittal in staging/, tables in mdl/rsk/ssr, etc.). available_tools +# UNIONS across the cascade — leaf restrictions don't drop ancestor +# entries — so this baseline propagates to every descendant. available_tools: [archive, browse, landing] # ── The slash / no-slash routing convention ──────────────────────────────── # -# Every directory URL has two forms, each served by a configurable -# tool: +# Every directory URL has two forms: # -# <dir>/ (trailing slash) → `dir_tool` — the directory view. -# Defaults to `browse` (file-tree -# navigator). This is the site-wide -# default; you rarely set it. -# <dir> (no slash) → `default_tool` — the "specialized -# app" for this folder (e.g. archive, -# transmittal, tables). If a folder -# declares no default_tool, the no- -# slash form just 302s to the slash -# form, so you land on `dir_tool`. +# <dir>/ (trailing slash) → `dir_tool` — the directory view +# (defaults to `browse`, the file-tree +# navigator; you rarely set it). +# <dir> (no slash) → `default_tool` — the specialized app +# for this folder (archive, transmittal, +# tables). If a folder declares no +# default_tool, the no-slash form 302s +# to the slash form. # -# JSON listing requests are unaffected by either key — they always get -# the raw directory listing, so the browse SPA (and any other client) -# can enumerate entries no matter what dir_tool/default_tool are. +# JSON listing requests are unaffected — they always get the raw +# directory listing, so the browse SPA (and any client) can enumerate +# entries regardless of dir_tool/default_tool. Both keys cascade +# leaf→root. # -# Both keys cascade leaf→root: a parent's default_tool applies to -# descendants unless a deeper level overrides it (browse set on -# working/ reaches working/alice/notes/ for free). The keys below set -# default_tool on the canonical folders; dir_tool is left unset -# everywhere, so the slash form is always `browse`. +# ── Canonical project structure (top-level party peers) ───────────────────── # -# ── Canonical project structure ──────────────────────────────────────────── +# A project is a top-level directory. Under it sit a FLAT set of +# physical, party-partitioned peers — there are no virtual aggregators: # -# Every ZDDC project lives at a top-level directory. Under it -# `archive/` is the ONLY real top-level folder; it contains a folder -# per party. Everything party-scoped (the SSR row, MDL/RSK rollups, -# WORM received/issued, the incoming drop zone, and the in-flight -# lifecycle slots working/staging/reviewing) lives uniformly under -# archive/<party>/. +# archive/<party>/{received,issued}/ the committed record. PURE +# WORM (one rule on archive/, no +# exceptions): write/delete +# stripped for all; create only +# for document_controller (the +# worm: list); admins bypass. +# Party record dirs appear on the +# first filing. +# incoming/<party>/ counterparty drop zone +# reviewing/<party>/<tracking>/ we review their submission +# working/<party>/ our drafts (edit-history on) +# staging/<party>/<tracking>/ assemble transmittals +# mdl/<party>/*.yaml master document list (tables) +# rsk/<party>/*.yaml risk register (tables) +# ssr/<party>.yaml submittal status register — AND +# the AUTHORITATIVE PARTY REGISTRY # -# Six top-level virtuals sit beside archive/ as resolver views: +# Party registry: `ssr/<party>.yaml` existence is the SINGLE source of +# truth for "party <party> exists". Creating it (rwc at ssr/, via the +# SSR form) is how a party is born. Every OTHER peer carries +# `party_source: ssr`, so you cannot create <peer>/<party>/… — archive +# filing included — until the ssr row exists; the server 409s otherwise. +# ssr/ itself has no party_source (it is the source). # -# ssr mdl rsk tables rollups across parties -# (with a synthesized $party column) -# working staging browse folder-nav listings of -# reviewing parties with non-empty content in -# the slot (in-flight filter). The -# virtual 302-redirects to the -# canonical archive/<party>/<slot>/. +# mdl/ and rsk/ AGGREGATE: the peer root renders ALL parties in one +# table (a $party column derived from the real subdir), <peer>/<party>/ +# shows that party's rows. ssr/ aggregates naturally (one flat file per +# party). $party is a real directory level, not a synthesized column. # -# Mkdir at the project root is restricted to `archive` plus system -# (_/.-prefixed) names; the six virtual aggregator names are rejected -# because the virtual would shadow any physical folder created at -# those URLs (see handler/fileapi.go). -# -# Everything below is expressed via the recursive paths: schema. None -# of the directories need to exist on disk — the cascade walker -# resolves behaviour from this declaration, so a fresh project lands -# on usable empty views at every well-known URL. -# -# Operators override any of this by mirroring the structure in an -# on-disk .zddc and changing what they need; on-disk values win. +# Mkdir at the project root is restricted to the peer names above plus +# system (_/.-prefixed) names (see handler/fileapi.go). Nothing here +# needs to exist on disk — the cascade resolves behaviour so a fresh +# project lands on usable empty views at every well-known URL. Operators +# override by mirroring this structure in an on-disk .zddc. paths: # First segment under root is the project name; "*" matches any. "*": - # Project-scoped baseline ACL. project_team and observer get read - # across the project; document_controller gets read + overwrite- - # existing (so people can ask them to fix a stuck file). None of - # the three gets `c` (create) at this level — that's granted only - # at the specific spots below (archive/, working/, staging/), so - # the doc controller can't make arbitrary folders. Grants here cap - # at deeper levels per deepest-match-wins, except where a deeper - # .zddc restates a fuller grant for the same principal. + # Project-scoped baseline ACL. project_team and observer read across + # the project; document_controller gets read + overwrite-existing. + # None gets `c` here — create is granted only at the specific peers + # below (archive/, ssr/, and the workspaces). acl: permissions: project_team: r observer: r document_controller: rw paths: - # ── Top-level virtual aggregators ─────────────────────────── - # - # Six resolver views, sibling to archive/. None of these - # materialise on disk; the server synthesises listings by - # walking archive/*/<slot>/ at request time and (for the - # tables rollups) rewrites file reads/writes back to canonical - # paths inside the per-party folders. ACL on each synthetic - # row is evaluated against the canonical archive/<party>/ - # chain, so party owners can edit their own rows and non- - # owners see them read-only. - ssr: - default_tool: tables - available_tools: [tables] - virtual: true - mdl: - default_tool: tables - available_tools: [tables] - virtual: true - # Edit-history default-on for the deliverables list (subtree- - # inheriting; see working/ note). Operators override per .zddc. - history: true - rsk: - default_tool: tables - available_tools: [tables] - virtual: true - # Edit-history default-on for the risk register. - history: true - working: - default_tool: browse - available_tools: [browse] - # Pure folder-nav aggregator over the per-party - # archive/<party>/working/ slots — same shape as staging/ and - # reviewing/ below. Nothing lives directly at <project>/working/: - # creating a folder here prompts for a party (browse's "New - # folder" picker) and lands it at archive/<party>/working/<name>, - # which carries its own history: true + auto-own convention. - virtual: true - # Edit-history default-on across the working subtree (markdown - # saves are snapshotted to .history/<stem>/). Subtree-inheriting, - # so it also covers any pre-reshape <project>/working/<…> homes - # that still hold content. Reads of recorded history never require - # this flag — turning it off only stops new snapshots. - history: true - staging: - default_tool: browse - available_tools: [browse] - virtual: true - reviewing: - default_tool: browse - available_tools: [browse] - virtual: true - - # ── Physical party root ───────────────────────────────────── + # ── The committed record: pure WORM ───────────────────────── archive: default_tool: archive - # The doc controller can create party subfolders here - # (archive/<party>/). Restate the full grant — deepest-match - # is per-principal replacement, so we re-list rw + add c. + # A record can only be filed for a registered party. + party_source: ssr + # The ONE WORM rule. Cascades to <party>/{received,issued}: + # write/delete stripped for everyone; create survives only for + # document_controller; admins bypass (the escape hatch). + worm: [document_controller] + # rwc so a DC can create party record dirs (WORM masks w/d to + # leave read + write-once-create). acl: permissions: document_controller: rwc + + # ── Authoritative party registry + submittal status register ─ + ssr: + default_tool: tables + available_tools: [tables] + # NO party_source — ssr/ IS the source of party existence. + # rwc: a DC registers a party by creating ssr/<party>.yaml and + # maintains its status (overwrite). Delete (de-register) is left + # to admins so a party with archived records is never orphaned. + acl: + permissions: + document_controller: rwc + history: true + records: + "*.yaml": + field_defaults: + kind: SSR + locked: [kind] + + # ── Inbound workspace: counterparty drop zone ─────────────── + incoming: + default_tool: classifier + available_tools: [classifier] + party_source: ssr + # The other party's DC uploads here (a deployment grants them + # cr, e.g. acl: { permissions: { "*@acme.com": cr } } at + # incoming/Acme/.zddc); OUR DC QCs via classifier and moves to + # archive/<party>/received. project_team has read only (observe). + acl: + permissions: + document_controller: rwcd paths: - # Second segment under archive/ is the party name. - "*": - # When the doc controller creates a party folder, the - # auto-own .zddc grants: - # - the creator's email rwcda (the standard auto_own - # mechanism) - # - the document_controller role rwcda (auto_own_roles - # below) so any DC in the role has full authority at - # every party, not just the parties they personally - # mkdir'd - # - # UNFENCED — so the project-level project_team:r still - # cascades through to received/issued/incoming. That - # lets the DC who created the party set up the counter- - # party's own .zddc afterward (e.g. granting them cr at - # incoming/). - # - # No `admins:` here by design. The DC role gets full - # authority via the role grant in the auto-own .zddc, not - # via subtree-admin status — so WORM masks at - # received/issued still bind them (they file write-once - # via the worm: list), and per-user fenced homes under - # working/ stay private to their creators. Admin - # elevation is reserved for the root admins list (the - # actual sudo-style escape hatch). + "*": # incoming/<party> auto_own: true - auto_own_roles: [document_controller] - # SSR record: the party folder's ssr.yaml carries this - # party's vendor / contract / status data. Scoped by - # filename pattern so the lock on `kind` only applies to - # ssr.yaml — the mdl/, rsk/, received/, working/, - # staging/, reviewing/ subfolders are untouched. No - # filename_format because identity is the party folder - # name, not a composed tracking number. + drop_target: true + + # ── Inbound workspace: review of their submission ─────────── + reviewing: + default_tool: browse + available_tools: [browse] + party_source: ssr + # The Plan-Review composite endpoint scaffolds a folder here per + # submittal under review, with a .zddc carrying received_path + # back to the canonical record in archive/<party>/received. + acl: + permissions: + project_team: cr + document_controller: rwcda + paths: + "*": # reviewing/<party> + auto_own: true + drop_target: true + + # ── Outbound workspace: our drafts (edit-history on) ──────── + working: + default_tool: browse + available_tools: [browse, classifier] + party_source: ssr + # Subtree-inheriting: every markdown save under working/ is + # snapshotted to .zddc.d/history/<stem>/ with a server-stamped + # audit line. Reads of recorded history never require this flag. + history: true + acl: + permissions: + project_team: cr + document_controller: rwcda + paths: + "*": # working/<party> — auto-owned by its creator + auto_own: true + drop_target: true + + # ── Outbound workspace: assemble transmittals ─────────────── + staging: + default_tool: transmittal + available_tools: [transmittal, classifier] + party_source: ssr + # project_team drops files (cr); after the drop the doc-control + # workflow owns it. DC gets rwcda — `d` for the cut to issued/, + # `a` so Plan Review can write staging/<tracking>/.zddc. + acl: + permissions: + project_team: cr + document_controller: rwcda + paths: + "*": # staging/<party> + auto_own: true + drop_target: true + + # ── Master document list (aggregates across parties) ──────── + mdl: + default_tool: tables # peer root: all-parties table + available_tools: [tables] + party_source: ssr + history: true + # The DC maintains the deliverables register (create/edit/delete + # rows). project_team reads it (inherited from the project level). + acl: + permissions: + document_controller: rwcd + # field_codes: constrain tracking-number components here (or + # higher in the cascade). Three kinds — enum / pattern / free; + # map-merge across levels. originator is folder-bound (below), + # so it is not listed here. Example: + # field_codes: + # discipline: { kind: enum, codes: { EL: Electrical, ME: Mechanical } } + # sequence: { kind: pattern, pattern: "[0-9]{4}" } + paths: + "*": # mdl/<party>: that party's rows, flat + default_tool: tables + # MDL records: each .yaml is an independent deliverable with + # its own composed tracking number. originator is the party + # folder (the record's own dir, distance 0 above + # mdl/<party>/<file>.yaml) and renders read-only — the folder + # is the single source of truth for the originator code. + # + # To add project-wide components (phase, area, …), override + # filename_format here AND mdl/<party>/{form,table}.yaml. records: - "ssr.yaml": + "*.yaml": + folder_fields: + originator: 0 + filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}" + + # ── Risk register (aggregates across parties) ─────────────── + rsk: + default_tool: tables + available_tools: [tables] + party_source: ssr + history: true + acl: + permissions: + document_controller: rwcd + paths: + "*": # rsk/<party> + default_tool: tables + # RSK records: each .yaml is a row of a parent rsk-type + # deliverable; the server auto-assigns -{row} within the + # row-scope group on POST-create. originator is folder-bound + # to the party folder, same as MDL. + records: + "*.yaml": + folder_fields: + originator: 0 + filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}-{row}" field_defaults: - kind: SSR - locked: [kind] - # ── Field-code vocabularies (field_codes:) ────────────── - # Each tracking-number component can be constrained by a - # field_codes entry at this (per-party) level — or higher - # if every party shares the same vocabulary. Three kinds: - # - # enum — closed code list; the label surfaces in form - # dropdowns and is enforced on POST/PUT. - # pattern — anchored regex (server wraps it with ^…$). - # free — no constraint; `description:` is help-text in - # the form UI. - # - # Map-merge across the cascade: a deeper .zddc can narrow - # or replace a single code without re-listing the others. - # `originator` is normally NOT listed here — it's bound to - # the party-folder name via folder_fields on the mdl/ + rsk/ - # records below, so the folder is its sole source of truth. - # - # field_codes: - # discipline: - # kind: enum - # codes: - # EL: "Electrical" - # ME: "Mechanical" - # CV: "Civil" - # sequence: - # kind: pattern - # pattern: "[0-9]{4}" # zero-padded 4-digit - # type: - # kind: free - # description: "Document category code within the discipline" - paths: - mdl: - default_tool: tables - available_tools: [tables] - # The mdl folder is virtual by convention — the - # tables tool serves it from the embedded default - # spec even when the on-disk folder doesn't exist. - virtual: true - # Edit-history default-on (markdown notes/specs saved here - # are snapshotted; .yaml records keep their own record- - # history path regardless). - history: true - # MDL records: each .yaml file is an independent - # deliverable with its own composed tracking number. - # No type lock — the row's body fields drive the - # filename; type is free-choice from the deployment's - # field_codes (see the field_codes block above). - # - # Default template — five required components plus an - # optional per-deliverable suffix that marks parts of - # the SAME deliverable (A = Appendix A, 01 = Sheet 1): - # - # originator-project-discipline-type-sequence[-suffix] - # - # `originator` is folder-bound: the server sets it from - # the party-folder name (folder_fields below) and the - # form renders it read-only — the party folder is the - # single source of truth for the originator code. - # - # To add PROJECT-WIDE components (phase, area, ...), - # override filename_format here AND add matching - # properties to mdl/form.yaml + columns to mdl/table.yaml. - # Pick once per project and apply to EVERY deliverable; - # mixing schemas within one project breaks lexical sort - # and filtering. Example: - # records: - # "*.yaml": - # folder_fields: { originator: 1 } - # filename_format: "{originator}-{phase}-{project}-{area}-{discipline}-{type}-{sequence}-{suffix?}" - records: - "*.yaml": - folder_fields: - originator: 1 - filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}" - rsk: - default_tool: tables - available_tools: [tables] - # Risk register — same virtual-by-convention pattern - # as mdl/. Embedded default-rsk spec backs it when no - # operator override is on disk. - virtual: true - # Edit-history default-on (same as mdl/). - history: true - # RSK records: each .yaml file is a row of a parent - # rsk-type deliverable. The table itself has a tracking - # number (same default components as an MDL deliverable - # with type=RSK); rows append a -{row} suffix the server - # auto-assigns within the row-scope group on POST-create. - # `originator` is folder-bound to the party folder, same - # as MDL. - # - # To add project-wide phase / area components, override - # BOTH filename_format AND row_scope_fields here — the - # scope fields decide which rows share a row-number - # sequence, so they must list the same components the - # filename does. - records: - "*.yaml": - folder_fields: - originator: 1 - filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}-{row}" - field_defaults: - type: RSK - locked: [type] - row_field: row - row_scope_fields: [originator, project, discipline, type, sequence, suffix] - incoming: - # incoming/ is the COUNTERPARTY's drop zone. The flow: - # 1. the other party's document controller uploads - # files here (a deployment grants them cr at this - # path, e.g. acl: { permissions: { "*@acme.com": cr } } - # at archive/Acme/incoming/.zddc — or they mkdir a - # dated subfolder under incoming/ and own it via - # auto_own) - # 2. OUR document controller QCs them via classifier - # (rename in place) and moves them to received/ - # (which needs delete here + worm-create there), - # ideally returning a signed transmittal in issued/ - # - # The normal project_team has only read here (inherited - # from the project level — they have no c/w) so they can - # see what's been dropped but not touch it. The - # document_controller grant restates rwcd so the QC + - # transfer-out workflow has the delete bit it needs. - default_tool: classifier - available_tools: [classifier] - auto_own: true - drop_target: true - acl: - permissions: - document_controller: rwcd - # received/ and issued/ are WORM (write-once-read-many). - # The `worm:` list marks the zone: - # - # - write (w) and delete (d) are stripped for EVERYONE - # - create (c) is stripped for everyone EXCEPT the - # principals listed — they get read + write-once- - # create ("cr") - # - read for non-listed principals is whatever the - # normal cascade ACL granted; the WORM list does not - # itself confer read to outsiders - # - admins (root / subtree) bypass entirely — the - # human escape hatch for mis-filed documents - # - # The baseline is an empty list: WORM zone, no - # create-capable principals — filing is locked until a - # deployment names a document controller, e.g. - # - # worm: ["doc-control@example.com"] - # - # at received/ (or issued/, or archive/<party>/, or - # wherever scopes it right). worm: lists UNION across the - # cascade, so a deeper .zddc adds more controllers. - received: - default_tool: archive - # document_controller may file write-once into the - # WORM zone. Their project-level rw is masked here - # to r; worm: restores write-once-create. - worm: [document_controller] - issued: - default_tool: archive - worm: [document_controller] - # ── In-flight lifecycle slots (NEW — nested per-party) ──── - # - # working/staging/reviewing now live inside each party - # folder instead of at the project root. The project- - # level <project>/{working,staging,reviewing} virtuals - # (declared above) are folder-nav views over these - # canonical per-party slots. - # ── In-flight ratchet ─────────────────────────────── - # - # The lifecycle slots form a one-way handoff: - # - # working/ → staging/ → issued/ (WORM) - # (full) (cr) (worm cr) - # - # At each step the previous role's modify rights drop: - # project_team iterates freely in working/; when they - # promote to staging/ they can't change it without doc- - # controller help; when DC publishes to issued/ even - # they can't change it without elevation. Each ACL - # grant below is the verb-set the ROLE keeps at that - # step; auto_own + auto_own_fenced sub-folder grants - # layer per-creator ownership on top of these. - working: - default_tool: browse - available_tools: [browse, classifier] - # Project_team gets read + create here so they can - # mkdir their own home folder (and any shared sub- - # folders). The auto_own_fenced declaration at the - # `*` child below makes the new folder a private home - # with rwcda for the creator (fenced from ancestors, - # so collaborators only join after the owner edits - # the home's .zddc to grant them access). - # - # `cr` instead of just `c` so an existing file at - # working/ root stays readable to all team members - # (cascade is per-level deepest-match — a single `c` - # would shadow the project-level `r`). - # - # `document_controller: rwcda` is restated here so a - # DC whose email is ALSO matched by project_team - # (typical when project_team is `*@example.com`) gets - # the higher grant via within-level union. Without - # the restatement, the cascade's deepest-level-wins - # would pick project_team's cr and shadow the DC's - # rwcda inherited from the party's auto-own .zddc. - # Same pattern applied at staging/ and reviewing/. - acl: - permissions: - project_team: cr - document_controller: rwcda - # working/ auto-owns the first creator + the per-user - # homes below. - auto_own: true - drop_target: true - # Edit-history: every markdown save under working/ (incl. - # the fenced per-user homes — history inherits through - # fences) is versioned into a sibling .history/ store with - # a server-stamped audit line (who + when). The live file - # stays the source of truth; GET <file>?history lists prior - # versions. See ZddcFile.History / handler.WriteTextWithHistory. - history: true - paths: - "*": # per-user home dir, fenced - default_tool: browse - available_tools: [browse, classifier] - auto_own: true - # Per-user home is private by default: the generated - # auto-own .zddc carries inherit:false so ancestor ACL - # grants don't reach inside. The user can edit the file - # to grant collaborators access. - auto_own_fenced: true - drop_target: true - staging: - default_tool: transmittal - available_tools: [transmittal, classifier] - # The ratchet step from working/. project_team gets - # `cr` — they can drop files (PUT new files at - # staging/) and read what's there, but cannot edit or - # delete after the drop. Once a file is in staging it - # belongs to the doc-controller workflow; the team - # member needs to ask DC to change it. - # - # Convention: project_team drops FILES at staging/, - # not sub-folders. A sub-folder mkdir'd by project_ - # team would trigger auto_own and grant them rwcda - # inside their own sub-folder (auto_own is path-keyed, - # not role-keyed — it fires for any creator). The - # auto_own here is preserved for DC's per-transmittal - # mkdir flow; project_team can keep to file drops to - # honour the "can't alter after" intent. - # - # DC gets rwcda explicitly — `d` for the cut to issued/, - # `a` so Plan Review can write the staging/<tracking>/.zddc - # the composite endpoint scaffolds. Restated here (not - # inherited from the party-level role grant) so the - # within-level union dominates project_team's cr for - # any DC matched by the team wildcard. - acl: - permissions: - project_team: cr - document_controller: rwcda - auto_own: true - drop_target: true - reviewing: - default_tool: browse - available_tools: [browse] - # reviewing/ is the doc-controller's draft-workspace - # area inside this party folder. The "Plan Review" - # composite endpoint scaffolds a physical folder here - # for each submittal under review, with a .zddc - # carrying received_path back to the canonical - # submittal in received/. Subtree-admin (inherited - # from the party-level admins:) so the doc - # controller can author per-folder .zddc files - # (originator ACL, planned_date). - # - # project_team gets `cr` so the originating team can - # create review-iteration folders alongside the - # Plan-Review-scaffolded ones. auto_own (unfenced - # here, unlike working/) gives the creator rwcda - # inside; siblings see the iteration via the project- - # level project_team:r cascade. - # - # document_controller: rwcda restated for the same - # reason as working/ + staging/ — keeps a DC matched - # by the project_team wildcard at full authority via - # within-level union. - acl: - permissions: - project_team: cr - document_controller: rwcda - auto_own: true - drop_target: true + type: RSK + locked: [type] + row_field: row + row_scope_fields: [originator, project, discipline, type, sequence, suffix] diff --git a/zddc/internal/zddc/ensure.go b/zddc/internal/zddc/ensure.go index 7c1fd20..ed2dc91 100644 --- a/zddc/internal/zddc/ensure.go +++ b/zddc/internal/zddc/ensure.go @@ -51,7 +51,7 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) { if len(parts) >= 2 { seg := strings.ToLower(parts[1]) - if seg == "archive" { + if IsProjectPeer(seg) { if err := resolveAt(1, seg); err != nil { return target, err } @@ -70,10 +70,10 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) { // EnsureCanonicalAncestors walks from fsRoot down to filepath.Dir(target), // creating any missing canonical-folder ancestor with MkdirAll(perm). -// For freshly-created auto-own ancestors (archive/<party>/, and the per- -// party lifecycle slots {working,staging,reviewing,incoming}), it also -// writes a creator-owned .zddc using principalEmail (skipped if -// principalEmail is empty). +// For freshly-created auto-own ancestors (the workspace party folders), +// it also writes a creator-owned .zddc using principalEmail (skipped if +// principalEmail is empty) — auto-own + fence are resolved per-dir via +// the .zddc cascade (AutoOwnAt / AutoOwnFencedAt). // // Returns the resolved version of target with on-disk casing substituted // for any canonical ancestor whose disk variant differs from the requested @@ -82,14 +82,12 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) { // // Canonical positions, relative to fsRoot: // -// - <project>/archive (the only physical project-root canonical; -// working/staging/reviewing/ssr/mdl/rsk at project root are virtual -// aggregators with no on-disk presence — writes targeting them -// must be rejected by the caller's project-root mkdir guard.) +// - <project>/<peer> for any top-level peer (IsProjectPeer: archive, +// incoming, working, staging, reviewing, mdl, rsk, ssr) — all are +// physical directories. // -// - <project>/archive/<party>/<canonical-party> where -// <canonical-party> ∈ {mdl, rsk, incoming, received, issued, -// working, staging, reviewing} +// - <project>/archive/<party>/<slot> where <slot> ∈ {received, issued} +// (IsPerPartySlot) — the WORM record folders. // // fsRoot and target must be absolute filesystem paths under the same // volume; target may not yet exist on disk. @@ -109,16 +107,6 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil return target, nil } - // Reject writes targeting top-level virtual aggregators — - // <project>/{ssr,mdl,rsk,working,staging,reviewing}/... — these - // resolve through ResolveVirtualView, not as physical paths. A - // caller writing under them bypassed the virtual resolver; the - // content belongs under archive/<party>/<slot>/ (browse's "New - // folder" picker prompts for the party). - if len(parts) >= 2 && IsVirtualAggregatorSlot(strings.ToLower(parts[1])) { - return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1]) - } - resolvedSegs := make([]string, len(parts)) copy(resolvedSegs, parts) @@ -161,18 +149,17 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil // Walk depth 1 (project) → deeper levels, resolving + tracking as we go. // Depth 0 is the project segment; not a canonical name. if len(parts) >= 2 { - // Depth 1 candidate: archive (only physical project-root canonical). + // Depth 1 candidate: any top-level peer (all physical now). seg := strings.ToLower(parts[1]) - if seg == "archive" { + if IsProjectPeer(seg) { if err := resolveAt(1, seg); err != nil { return target, err } } } - // Depth 3 candidate (archive/<party>/<canonical-party>): the eight - // physical per-party slots. Only meaningful when depth 1 is - // "archive". + // Depth 3 candidate (archive/<party>/<slot>): the WORM record slots + // received/issued. Only meaningful when depth 1 is "archive". if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") { seg := strings.ToLower(parts[3]) if IsPerPartySlot(seg) { diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index 53e2e1e..f855a3c 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -263,6 +263,17 @@ type ZddcFile struct { // not its descendants. Defaults (nil): no drop zone. DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"` + // PartySource names the registry that gates party-folder creation + // under THIS peer. When set (e.g. "ssr"), a new <party> segment may + // be created here only if the party is registered — i.e. the registry + // entry <project>/<party_source>/<party>.yaml (or .../<party>/) exists + // — otherwise the server 409s. The authoritative party registry is + // ssr/ (a party exists iff ssr/<party>.yaml exists); ssr/ itself sets + // no party_source. Leaf-only — the property describes THIS peer dir, + // checked via PartyRegistered at party-folder-creating entrypoints. + // Empty: no party gating. + PartySource string `yaml:"party_source,omitempty" json:"party_source,omitempty"` + // History enables server-managed edit-history versioning for text // (markdown) writes in this subtree. When true, each save of a // history-eligible file (see handler.IsTextHistoryCandidate) snapshots diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index 9a20e19..d069b0d 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -1,6 +1,7 @@ package zddc import ( + "os" "path/filepath" "strings" ) @@ -128,6 +129,45 @@ func DropTargetAt(fsRoot, dirPath string) bool { return false } +// PartySourceAt returns the registry name that gates party-folder +// creation under THIS peer directory (e.g. "ssr"), or "" if this peer +// does no party gating. Leaf-only — the property describes the peer dir +// (working/, archive/, …), not its party-folder children. +func PartySourceAt(fsRoot, dirPath string) string { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return "" + } + leaf := leafLevel(chain) + if leaf.PartySource != "" { + return leaf.PartySource + } + return chain.Embedded.PartySource +} + +// PartyRegistered reports whether party is registered in the named +// registry under projectAbs (e.g. source="ssr" → the registry is +// <projectAbs>/ssr/). A party exists iff its registry entry exists, +// checked as either a flat row file <registry>/<party>.yaml or a folder +// <registry>/<party>/ (so the key works for flat-file and folder-style +// registers). An empty source means "no gating" and returns true. +func PartyRegistered(projectAbs, source, party string) bool { + if source == "" { + return true + } + if party == "" { + return false + } + reg := filepath.Join(projectAbs, source) + if fi, err := os.Stat(filepath.Join(reg, party+".yaml")); err == nil && fi.Mode().IsRegular() { + return true + } + if fi, err := os.Stat(filepath.Join(reg, party)); err == nil && fi.IsDir() { + return true + } + return false +} + // VirtualAt reports whether THIS specific directory is declared as // purely virtual (never materialise on disk). Leaf-only: the virtual // property describes a particular path, not a subtree. A child of a @@ -264,36 +304,37 @@ func ChildrenDeclaredAt(fsRoot, dirPath string) []string { } // CanonicalFolderAt returns the canonical-folder name for THIS specific -// directory — one of "archive", "working", "staging", "reviewing", -// "incoming", "received", "issued", "mdl", "rsk" — or "" if the path -// is not at a canonical-folder slot. +// directory — one of the top-level peers (archive, incoming, working, +// staging, reviewing, mdl, rsk, ssr) or the WORM record slots +// (received, issued) — or "" if the path is not at a canonical slot. // -// Detection is structural against the canonical project layout declared -// in defaults.zddc.yaml: +// Detection is structural against the flat-peer layout declared in +// defaults.zddc.yaml: // -// - top-level <project>/archive is the only physical project-root -// canonical slot (the working/staging/reviewing/ssr/mdl/rsk URLs -// at project root are virtual aggregators, not on-disk folders). -// - third-level archive/<party>/{mdl,rsk,incoming,received,issued, -// working,staging,reviewing} are the physical per-party canonical -// slots. +// - second-level <project>/<peer> for any top-level peer. +// - third-level <project>/<peer>/<party> reports its peer (slot) for +// the workspace/register peers (not archive), so the SPA can scope +// party-level context-menu actions. +// - fourth-level <project>/archive/<party>/{received,issued} are the +// WORM record slots. // -// Operators don't rename these slots (the cascade keys them by -// literal name); a custom layout that does is on its own. -// -// Used by the browse SPA to scope-gate context-menu actions (Accept, -// Stage/Unstage, Create Transmittal folder) without re-implementing the -// cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder header. +// Operators don't rename these slots (the cascade keys them by literal +// name). Used by the browse SPA to scope-gate context-menu actions +// (Accept, Stage/Unstage, Create Transmittal) without re-implementing +// the cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder +// header. func CanonicalFolderAt(fsRoot, dirPath string) string { segs := resolvePathSegments(fsRoot, dirPath) - // <project>/<folder> — only archive/ is physical at project root. - if len(segs) == 2 { - if segs[1] == "archive" { - return "archive" - } - return "" + // <project>/<peer> — all top-level peers are physical canonical slots. + if len(segs) == 2 && IsProjectPeer(segs[1]) { + return segs[1] } - // <project>/archive/<party>/<folder> + // <project>/<peer>/<party> — the party folder under a workspace/ + // register peer reports its peer; archive's party folder is not a slot. + if len(segs) == 3 && IsProjectPeer(segs[1]) && segs[1] != "archive" { + return segs[1] + } + // <project>/archive/<party>/<slot> — the WORM record slots. if len(segs) == 4 && segs[1] == "archive" && IsPerPartySlot(segs[3]) { return segs[3] } @@ -324,6 +365,9 @@ func isZeroZddcFile(zf ZddcFile) bool { zf.DropTarget != nil || zf.Inherit != nil { return false } + if zf.PartySource != "" { + return false + } if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" || zf.PlannedResponseDate != "" { return false diff --git a/zddc/internal/zddc/slots.go b/zddc/internal/zddc/slots.go index 89cadf0..8dcf56b 100644 --- a/zddc/internal/zddc/slots.go +++ b/zddc/internal/zddc/slots.go @@ -2,29 +2,34 @@ package zddc import "strings" -// Canonical project slots — the fixed lifecycle shape of a project. +// Canonical project shape — the fixed set of top-level peers and the +// per-party record slots under archive/. // -// The binary wires bespoke behavior to each of these names (transmittal at -// staging/, plan-review at received/, tables rollups at mdl/rsk, folder-nav -// at working/staging/reviewing), so the SET of slot names is a deliberate -// hard rule rather than a cascade key. The point of this file is that the -// set lives in ONE place: handlers ask the predicates below instead of -// re-listing the names, so adding or adjusting a slot is a single edit, not -// a hunt across ensure.go / fileapi.go / virtualviews.go / lookups.go. +// The binary wires bespoke behavior to these names (tables at +// mdl/rsk/ssr, transmittal at staging/, WORM at archive/, the party +// registry at ssr/), so the SET of names is a deliberate hard rule +// rather than a cascade key. The point of this file is that the set +// lives in ONE place: handlers ask the predicates below instead of +// re-listing the names, so adding or adjusting a peer is a single edit. // -// Note the layering: the slot NAMES are hard-coded here, but per-slot -// BEHAVIOR (default_tool, history, worm, auto_own, virtual, …) stays -// cascade-driven in defaults.zddc.yaml + on-disk .zddc. This file is -// identity/shape only. +// Note the layering: the names are hard-coded here, but per-peer +// BEHAVIOR (default_tool, worm, party_source, history, auto_own, …) +// stays cascade-driven in defaults.zddc.yaml + on-disk .zddc. This file +// is identity/shape only. var ( - // rowSlots: project-level tables rollups (ssr) + the per-party record - // folders they aggregate (mdl, rsk). + // projectPeers: the flat set of physical directories permitted + // directly under a project root. archive/ is the committed record; + // the rest are party-partitioned workspaces/registers. Mkdir at the + // project root is restricted to these plus system (_/.-prefixed) + // names. + projectPeers = []string{"archive", "incoming", "working", "staging", "reviewing", "mdl", "rsk", "ssr"} + // rowSlots: the tables-rendered register peers. mdl/rsk aggregate + // across their party subdirs; ssr is flat (one row file per party) + // and is also the authoritative party registry. rowSlots = []string{"ssr", "mdl", "rsk"} - // folderNavSlots: project-level folder-nav aggregators. - folderNavSlots = []string{"working", "staging", "reviewing"} - // perPartySlots: the physical lifecycle folders under archive/<party>/. - // (ssr is a file — ssr.yaml — not a folder, so it's not here.) - perPartySlots = []string{"incoming", "received", "issued", "mdl", "rsk", "working", "staging", "reviewing"} + // perPartySlots: the canonical lifecycle folders under + // archive/<party>/ — the committed record, both WORM. + perPartySlots = []string{"received", "issued"} ) func slotIn(set []string, s string) bool { @@ -36,26 +41,14 @@ func slotIn(set []string, s string) bool { return false } -// IsRowSlot reports whether slot is a tables-rollup slot (ssr/mdl/rsk). +// IsProjectPeer reports whether name is one of the fixed top-level peers +// permitted directly under a project root. +func IsProjectPeer(name string) bool { return slotIn(projectPeers, strings.ToLower(name)) } + +// IsRowSlot reports whether slot is a tables-rendered register peer +// (ssr/mdl/rsk). func IsRowSlot(slot string) bool { return slotIn(rowSlots, slot) } -// IsFolderNavSlot reports whether slot is a folder-nav lifecycle slot -// (working/staging/reviewing). -func IsFolderNavSlot(slot string) bool { return slotIn(folderNavSlots, slot) } - -// IsVirtualAggregatorSlot reports whether slot is one of the six -// project-level virtual aggregators (row rollups + folder-nav). These have -// no physical presence at the project root; content is party-scoped. -func IsVirtualAggregatorSlot(slot string) bool { - return IsRowSlot(slot) || IsFolderNavSlot(slot) -} - -// IsPerPartySlot reports whether slot is a physical per-party lifecycle -// folder under archive/<party>/. +// IsPerPartySlot reports whether slot is a canonical lifecycle folder +// under archive/<party>/ (received/issued). func IsPerPartySlot(slot string) bool { return slotIn(perPartySlots, slot) } - -// virtualAggregatorAlternation returns the six aggregator slot names as a -// regex alternation (rowSlots then folderNavSlots) for virtualViewRE. -func virtualAggregatorAlternation() string { - return strings.Join(append(append([]string{}, rowSlots...), folderNavSlots...), "|") -} diff --git a/zddc/internal/zddc/virtualviews.go b/zddc/internal/zddc/virtualviews.go index 9d507e2..965ced5 100644 --- a/zddc/internal/zddc/virtualviews.go +++ b/zddc/internal/zddc/virtualviews.go @@ -9,336 +9,54 @@ import ( "strings" ) -// Virtual project-level views. +// Project-level helpers for the physical top-level peer layout. // -// Six aggregators live at <project>/, all sibling to the only real -// top-level directory archive/. None of them materialise on disk; the -// server synthesises listings by walking archive/*/ at request time -// and (for the tables rollups) rewrites file reads/writes back to -// canonical paths inside the per-party folders. -// -// Two aggregation shapes: -// -// Row rollups (tables tool): -// <project>/ssr one row per party folder under archive/, backed -// by archive/<party>/ssr.yaml; synthesised key -// `name: <party>` is the identity column. -// <project>/mdl one row per *.yaml under archive/<party>/mdl/; -// synthesised key `$party: <party>` is the -// read-only source-party column. ($-prefix -// prevents collision with user-defined fields.) -// <project>/rsk same as mdl but for archive/<party>/rsk/. -// -// Folder-nav (browse tool): -// <project>/working list of archive/<party>/working/ that have -// non-empty content (in-flight filter). Per- -// party click 302s to the canonical path. -// <project>/staging same shape over archive/<party>/staging/. -// <project>/reviewing same shape over archive/<party>/reviewing/. -// -// ACL on each synthetic row/folder is evaluated against the canonical -// archive/<party>/ chain, so party owners can edit their own data and -// non-owners see them read-only. -// -// URL conventions -// -// /<project>/ssr/ → directory listing -// /<project>/ssr/table.yaml | form.yaml → embedded default spec -// /<project>/ssr/<party>.yaml → reads <project>/archive/<party>/ssr.yaml -// /<project>/ssr/<party>.yaml.html → form edit (form recognizer) -// /<project>/ssr/form.html → "+ Add row" — SSR create -// -// /<project>/mdl/ → rollup listing -// /<project>/mdl/table.yaml | form.yaml → embedded default project-rollup spec -// /<project>/mdl/<party>__<file>.yaml → reads <project>/archive/<party>/mdl/<file>.yaml -// /<project>/mdl/<party>__<file>.yaml.html → form edit -// -// /<project>/rsk/ → analogous -// -// /<project>/working/ → folder-nav listing (parties with content) -// /<project>/working/<party>/[<rest>] → 302 to /<project>/archive/<party>/working/<rest> -// /<project>/staging/, /<project>/reviewing/ → analogous folder-nav -// -// Modeled on virtualreceived.go: one resolver produces canonical -// paths; every caller (listing builder, file API rewrite, form -// recognizer) reads its policy chain from the canonical path. +// There is no virtual URL space: every record row is addressed at its +// real path (mdl/<party>/<file>.yaml, ssr/<party>.yaml). mdl/ and rsk/ +// AGGREGATE across their party subdirs — the peer root renders one +// table of every party's rows (a $party column derived from the real +// subdir name), while <peer>/<party>/ shows that party's rows flat. +// ssr/ aggregates naturally (one flat ssr/<party>.yaml per party) and +// is the authoritative party registry. These helpers back the +// aggregation listing and party-name validation. -// VirtualViewKind classifies a resolved virtual URL. -type VirtualViewKind int - -const ( - VirtualViewNone VirtualViewKind = iota - VirtualViewSSRRoot - VirtualViewSSRSpec - VirtualViewSSRRow - VirtualViewMDLRoot - VirtualViewMDLSpec - VirtualViewMDLRow - VirtualViewRSKRoot - VirtualViewRSKSpec - VirtualViewRSKRow - // Folder-nav: top-level listing of parties with non-empty - // content in the named lifecycle slot. - VirtualViewFolderNavRoot - // Folder-nav: a per-party URL under one of the folder-nav - // roots. Resolves to a 302 redirect at canonical - // /<project>/archive/<party>/<slot>/<rest>. - VirtualViewFolderNavRedir -) - -// IsRowKind reports whether k targets a per-party row file (true for -// SSRRow, MDLRow, RSKRow). -func (k VirtualViewKind) IsRowKind() bool { - switch k { - case VirtualViewSSRRow, VirtualViewMDLRow, VirtualViewRSKRow: - return true - } - return false -} - -// IsSpecKind reports whether k targets a virtual table.yaml/form.yaml. -func (k VirtualViewKind) IsSpecKind() bool { - switch k { - case VirtualViewSSRSpec, VirtualViewMDLSpec, VirtualViewRSKSpec: - return true - } - return false -} - -// IsRootKind reports whether k targets the listing-level URL of a -// virtual view. -func (k VirtualViewKind) IsRootKind() bool { - switch k { - case VirtualViewSSRRoot, VirtualViewMDLRoot, VirtualViewRSKRoot, - VirtualViewFolderNavRoot: - return true - } - return false -} - -// IsFolderNavKind reports whether k is one of the folder-nav virtuals -// (working, staging, reviewing). Folder-nav views surface a per-party -// listing at the root and 302 redirect at every per-party URL. -func (k VirtualViewKind) IsFolderNavKind() bool { - switch k { - case VirtualViewFolderNavRoot, VirtualViewFolderNavRedir: - return true - } - return false -} - -// VirtualViewResolution captures the result of mapping a URL onto -// one of the project-level virtual views. All fields are populated -// only when Resolved is true. -type VirtualViewResolution struct { - Resolved bool - Kind VirtualViewKind - - Project string // "<project>" - ProjectURL string // "/<project>/" - ProjectAbs string // <fsRoot>/<project> - - Slot string // "ssr", "mdl", "rsk", "working", "staging", "reviewing" - SlotURL string // "/<project>/<slot>/" - - // Populated for VirtualView*Spec kinds: "table.yaml" or "form.yaml". - SpecBase string - - // Populated for VirtualView*Row kinds. - Party string // party folder name (e.g. "0330C1") - PartyArchive string // <fsRoot>/<project>/archive/<party> - CanonicalAbs string // underlying file on disk - CanonicalURL string // /<project>/archive/<party>/... - SchemaAbs string // SSR only — <party>/ssr.form.yaml (may not exist; falls back to embedded) - RowFilename string // MDL/RSK rollups only — e.g. "D-001.yaml" - - // Populated for VirtualViewFolderNavRedir. The path component - // AFTER the party — empty for /<project>/<slot>/<party>/ itself, - // or the URL-decoded sub-path for deeper URLs. The redirect - // target is /<project>/archive/<party>/<slot>/<RedirRest>. - RedirRest string -} - -// virtualViewRE matches /<project>/<slot>[/<rest>] where slot is one -// of the canonical virtual view names. Capture 1 = project, capture -// 2 = slot, capture 3 = rest (may be empty). -var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(` + virtualAggregatorAlternation() + `)(?:/(.*))?$`) - -// partyNameRE matches the SSR schema's `name` pattern. Same regex -// used at row-resolution time so URLs with invalid party tokens fail -// resolution cleanly instead of producing impossible canonical paths. +// partyNameRE matches a valid party folder / registry-row token — +// starts with [A-Za-z0-9], then any of [A-Za-z0-9.-]. var partyNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]*$`) -// ValidPartyName reports whether s is a valid party folder name — -// starts with [A-Za-z0-9], then any of [A-Za-z0-9.-]. Used by URL +// ValidPartyName reports whether s is a valid party name. Used by URL // resolution AND by the SSR create handler to validate user input. func ValidPartyName(s string) bool { return partyNameRE.MatchString(s) } -// IsFolderNavSlot / IsRowSlot / IsVirtualAggregatorSlot / IsPerPartySlot -// live in slots.go (the single canonical-slot registry). - // planReviewURLRE matches /<project>/archive/<party>/received/<tracking>/ // — the only URL shape Plan Review accepts. Trailing slash optional. var planReviewURLRE = regexp.MustCompile(`^/[^/]+/archive/[^/]+/received/[^/]+/?$`) // IsPlanReviewURL reports whether urlPath is a directory URL eligible // for the Plan Review composite endpoint — i.e. it points at the -// canonical received/<tracking>/ folder under archive/<party>/. Used -// to surface X-ZDDC-On-Plan-Review on directory responses so the -// browse client can show/hide the right-click menu item. -// -// Eligibility is purely structural — no cascade lookup, no per- -// project configuration. The handler-side authorisation check still -// gates the actual operation. +// canonical received/<tracking>/ folder under archive/<party>/. +// Eligibility is purely structural; the handler-side authorisation +// check still gates the actual operation. func IsPlanReviewURL(urlPath string) bool { return planReviewURLRE.MatchString(urlPath) } -// ResolveVirtualView inspects urlPath and returns a populated -// resolution iff the URL targets one of the project-level virtual -// views (ssr/, mdl/, rsk/, working/, staging/, reviewing/). -// Resolved=false on non-match. -// -// The resolver does NOT check that the project / party / row file -// actually exist on disk — that's the caller's job (handlers use -// the canonical path; listings synthesize from real disk state). -// -// urlPath must be a server-relative URL with one leading slash. -// Trailing slashes are tolerated for root kinds. -func ResolveVirtualView(fsRoot, urlPath string) VirtualViewResolution { - var out VirtualViewResolution - if urlPath == "" || urlPath[0] != '/' { - return out +// StripYAMLHTML returns urlPath with a trailing ".html" stripped iff +// the URL has the form-edit shape ".../<name>.yaml.html". Otherwise +// returns urlPath unchanged + false. The form recognizer calls this +// to map a form-edit URL onto the underlying data file. +func StripYAMLHTML(urlPath string) (string, bool) { + if strings.HasSuffix(urlPath, ".yaml.html") { + return strings.TrimSuffix(urlPath, ".html"), true } - trimmed := strings.TrimSuffix(urlPath, "/") - m := virtualViewRE.FindStringSubmatch(trimmed) - if m == nil { - return out - } - project := m[1] - slot := m[2] - rest := m[3] - - if project == "" || strings.HasPrefix(project, ".") || strings.HasPrefix(project, "_") { - return out - } - projectAbs := filepath.Join(fsRoot, filepath.FromSlash(project)) - if !strings.HasPrefix(projectAbs, fsRoot+string(filepath.Separator)) && projectAbs != fsRoot { - return out - } - - out.Project = project - out.ProjectURL = "/" + project + "/" - out.ProjectAbs = projectAbs - out.Slot = slot - out.SlotURL = "/" + project + "/" + slot + "/" - - if rest == "" { - if IsFolderNavSlot(slot) { - out.Kind = VirtualViewFolderNavRoot - } else { - switch slot { - case "ssr": - out.Kind = VirtualViewSSRRoot - case "mdl": - out.Kind = VirtualViewMDLRoot - case "rsk": - out.Kind = VirtualViewRSKRoot - } - } - out.Resolved = true - return out - } - - // Folder-nav slots: any non-empty rest is a per-party redirect - // target. /<project>/working/<party>[/...] → 302 to canonical - // /<project>/archive/<party>/working[/...]. - if IsFolderNavSlot(slot) { - // Split off the party (first segment) from the rest. - party := rest - var redirRest string - if idx := strings.Index(rest, "/"); idx >= 0 { - party = rest[:idx] - redirRest = rest[idx+1:] - } - if !ValidPartyName(party) { - return out - } - out.Party = party - out.PartyArchive = filepath.Join(projectAbs, "archive", party) - out.RedirRest = redirRest - out.CanonicalURL = "/" + project + "/archive/" + party + "/" + slot + "/" - if redirRest != "" { - out.CanonicalURL += redirRest - } - out.Kind = VirtualViewFolderNavRedir - out.Resolved = true - return out - } - - if rest == "table.yaml" || rest == "form.yaml" { - switch slot { - case "ssr": - out.Kind = VirtualViewSSRSpec - case "mdl": - out.Kind = VirtualViewMDLSpec - case "rsk": - out.Kind = VirtualViewRSKSpec - } - out.SpecBase = rest - out.Resolved = true - return out - } - - // Row files — must be a single segment ending in .yaml. - if strings.Contains(rest, "/") || !strings.HasSuffix(rest, ".yaml") { - return out - } - name := strings.TrimSuffix(rest, ".yaml") - - if slot == "ssr" { - if !ValidPartyName(name) { - return out - } - out.Party = name - out.PartyArchive = filepath.Join(projectAbs, "archive", name) - out.CanonicalAbs = filepath.Join(out.PartyArchive, "ssr.yaml") - out.CanonicalURL = "/" + project + "/archive/" + name + "/ssr.yaml" - out.SchemaAbs = filepath.Join(out.PartyArchive, "ssr.form.yaml") - out.Kind = VirtualViewSSRRow - out.Resolved = true - return out - } - - // MDL/RSK rollup row — <party>__<file>.yaml. - idx := strings.Index(name, "__") - if idx <= 0 || idx >= len(name)-2 { - return out - } - party := name[:idx] - rowBase := name[idx+2:] - if !ValidPartyName(party) || rowBase == "" || strings.Contains(rowBase, "__") { - return out - } - out.Party = party - out.PartyArchive = filepath.Join(projectAbs, "archive", party) - out.RowFilename = rowBase + ".yaml" - out.CanonicalAbs = filepath.Join(out.PartyArchive, slot, out.RowFilename) - out.CanonicalURL = "/" + project + "/archive/" + party + "/" + slot + "/" + out.RowFilename - switch slot { - case "mdl": - out.Kind = VirtualViewMDLRow - case "rsk": - out.Kind = VirtualViewRSKRow - } - out.Resolved = true - return out + return urlPath, false } -// IsSSRCreateURL reports whether urlPath is /<project>/ssr/form.html -// — the SSR "+ Add row" target. Returns the project name when matched. +// IsSSRCreateURL reports whether urlPath is /<project>/ssr/form.html — +// the SSR "+ Register party" target. Returns the project name when +// matched. The handler writes the new ssr/<party>.yaml registry row. func IsSSRCreateURL(urlPath string) (string, bool) { if urlPath == "" || urlPath[0] != '/' { return "", false @@ -356,11 +74,10 @@ func IsSSRCreateURL(urlPath string) (string, bool) { // IsRollupCreateURL reports whether urlPath is // /<project>/(mdl|rsk)/form.html — the "+ Add row" target on a -// project-level MDL or RSK rollup view. Returns the project name + -// slot ("mdl" or "rsk") when matched. The rollup-create handler -// reads a `party` field from the body and routes the new row into -// <project>/archive/<party>/<slot>/. -func IsRollupCreateURL(urlPath string) (project, slot string, ok bool) { +// project-level MDL or RSK aggregate view. Returns the project name + +// peer ("mdl" or "rsk") when matched. The handler reads a `party` field +// from the body and routes the new row into <project>/<peer>/<party>/. +func IsRollupCreateURL(urlPath string) (project, peer string, ok bool) { if urlPath == "" || urlPath[0] != '/' { return "", "", false } @@ -378,25 +95,13 @@ func IsRollupCreateURL(urlPath string) (project, slot string, ok bool) { return project, parts[1], true } -// StripYAMLHTML returns urlPath with a trailing ".html" stripped iff -// the URL has the form-edit shape ".../<name>.yaml.html". Otherwise -// returns urlPath unchanged + false. The form recognizer calls this -// before passing the data URL into ResolveVirtualView. -func StripYAMLHTML(urlPath string) (string, bool) { - if strings.HasSuffix(urlPath, ".yaml.html") { - return strings.TrimSuffix(urlPath, ".html"), true - } - return urlPath, false -} - -// ListSSRParties returns the party folder names that exist under -// <project>/archive/. Names are filtered through ValidPartyName so a -// hand-created folder with a weird name (e.g. "0330C1 (draft)") won't -// confuse the rest of the resolver. Returns nil + nil when archive/ -// doesn't exist on disk. -func ListSSRParties(fsRoot, projectAbs string) ([]string, error) { - archive := filepath.Join(projectAbs, "archive") - entries, err := os.ReadDir(archive) +// ListParties returns the registered party names under <project>/ssr/ +// — one per ssr/<party>.yaml file (the authoritative party registry). +// Names are filtered through ValidPartyName. Returns nil + nil when +// ssr/ doesn't exist on disk. +func ListParties(projectAbs string) ([]string, error) { + reg := filepath.Join(projectAbs, "ssr") + entries, err := os.ReadDir(reg) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil @@ -405,10 +110,10 @@ func ListSSRParties(fsRoot, projectAbs string) ([]string, error) { } out := make([]string, 0, len(entries)) for _, e := range entries { - if !e.IsDir() { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { continue } - name := e.Name() + name := strings.TrimSuffix(e.Name(), ".yaml") if !ValidPartyName(name) { continue } @@ -418,61 +123,59 @@ func ListSSRParties(fsRoot, projectAbs string) ([]string, error) { return out, nil } -// VirtualRollupRow describes one synthetic row in a project-level -// MDL or RSK rollup. -type VirtualRollupRow struct { - Party string // source party folder - Filename string // e.g. "D-001.yaml" - SyntheticName string // e.g. "0330C1__D-001.yaml" — used in URLs - CanonicalAbs string // underlying file on disk +// RollupRow describes one row in an mdl/ or rsk/ aggregate table, +// gathered from the physical <project>/<peer>/<party>/<file>.yaml. +type RollupRow struct { + Party string // source party (real subdir name → the $party column) + Filename string // e.g. "ACME-PRJ-EL-SPC-0001.yaml" + Abs string // underlying file on disk + RelURL string // /<project>/<peer>/<party>/<file>.yaml } -// ListRollupRows walks <project>/archive/*/<slot>/ and returns one -// synthetic row per *.yaml file. slot must be "mdl" or "rsk". -// Returns rows sorted by (party, filename). -// -// Skipped: -// - filenames containing "__" (would break the party__file split) -// - "table.yaml" and "form.yaml" (operator spec/schema, not rows) -// - any non-*.yaml file -// - parties with invalid folder names (filtered by ListSSRParties) -func ListRollupRows(fsRoot, projectAbs, slot string) ([]VirtualRollupRow, error) { - if slot != "mdl" && slot != "rsk" { - return nil, errors.New("ListRollupRows: slot must be mdl or rsk") +// ListRollupRows walks <project>/<peer>/*/*.yaml (peer = mdl|rsk) and +// returns one row per *.yaml file, sorted by (party, filename). The +// $party column is the real subdir name. Skipped: table.yaml / form.yaml +// specs, non-*.yaml files, and party dirs with invalid names. Returns +// nil + nil when the peer dir doesn't exist on disk. +func ListRollupRows(projectAbs, peer string) ([]RollupRow, error) { + if peer != "mdl" && peer != "rsk" { + return nil, errors.New("ListRollupRows: peer must be mdl or rsk") } - parties, err := ListSSRParties(fsRoot, projectAbs) + peerAbs := filepath.Join(projectAbs, peer) + partyEntries, err := os.ReadDir(peerAbs) if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } return nil, err } - out := make([]VirtualRollupRow, 0, len(parties)) - for _, party := range parties { - slotDir := filepath.Join(projectAbs, "archive", party, slot) - entries, err := os.ReadDir(slotDir) + project := filepath.Base(projectAbs) + var out []RollupRow + for _, pe := range partyEntries { + if !pe.IsDir() { + continue + } + party := pe.Name() + if !ValidPartyName(party) { + continue + } + partyDir := filepath.Join(peerAbs, party) + rows, err := os.ReadDir(partyDir) if err != nil { - if errors.Is(err, os.ErrNotExist) { - continue - } return nil, err } - for _, e := range entries { - if e.IsDir() { + for _, e := range rows { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { continue } - name := e.Name() - if !strings.HasSuffix(name, ".yaml") { + if e.Name() == "table.yaml" || e.Name() == "form.yaml" { continue } - if name == "table.yaml" || name == "form.yaml" { - continue - } - if strings.Contains(name, "__") { - continue - } - out = append(out, VirtualRollupRow{ - Party: party, - Filename: name, - SyntheticName: party + "__" + name, - CanonicalAbs: filepath.Join(slotDir, name), + out = append(out, RollupRow{ + Party: party, + Filename: e.Name(), + Abs: filepath.Join(partyDir, e.Name()), + RelURL: "/" + project + "/" + peer + "/" + party + "/" + e.Name(), }) } } @@ -484,52 +187,3 @@ func ListRollupRows(fsRoot, projectAbs, slot string) ([]VirtualRollupRow, error) }) return out, nil } - -// ListPartyDirsInSlot walks <project>/archive/*/<slot>/ and returns -// the party folder names whose slot directory exists AND has -// non-empty content (the "in-flight" filter). slot must be one of -// "working", "staging", "reviewing". Returns nil + nil when archive/ -// doesn't exist on disk. -// -// Used by the folder-nav virtuals at <project>/<slot>/ to list only -// parties that have something to show. Parties whose archive/<party>/ -// <slot>/ is absent or contains only system files (.zddc) are -// suppressed from the listing. -func ListPartyDirsInSlot(fsRoot, projectAbs, slot string) ([]string, error) { - if !IsFolderNavSlot(slot) { - return nil, errors.New("ListPartyDirsInSlot: slot must be working/staging/reviewing") - } - parties, err := ListSSRParties(fsRoot, projectAbs) - if err != nil { - return nil, err - } - out := make([]string, 0, len(parties)) - for _, party := range parties { - slotDir := filepath.Join(projectAbs, "archive", party, slot) - if !slotDirHasContent(slotDir) { - continue - } - out = append(out, party) - } - sort.Strings(out) - return out, nil -} - -// slotDirHasContent reports whether slotDir is a directory with at -// least one entry that isn't a .-prefixed system file. Treats -// .zddc-only directories as empty so the folder-nav listing doesn't -// fire for parties whose lifecycle slot was scaffolded but never -// populated with real work. -func slotDirHasContent(slotDir string) bool { - entries, err := os.ReadDir(slotDir) - if err != nil { - return false - } - for _, e := range entries { - if strings.HasPrefix(e.Name(), ".") { - continue - } - return true - } - return false -} diff --git a/zddc/internal/zddc/virtualviews_test.go b/zddc/internal/zddc/virtualviews_test.go index 8c56113..19c762f 100644 --- a/zddc/internal/zddc/virtualviews_test.go +++ b/zddc/internal/zddc/virtualviews_test.go @@ -7,331 +7,6 @@ import ( "testing" ) -func TestResolveVirtualView_Roots(t *testing.T) { - root := t.TempDir() - cases := []struct { - url string - want VirtualViewKind - }{ - {"/Project/ssr", VirtualViewSSRRoot}, - {"/Project/ssr/", VirtualViewSSRRoot}, - {"/Project/mdl", VirtualViewMDLRoot}, - {"/Project/mdl/", VirtualViewMDLRoot}, - {"/Project/rsk", VirtualViewRSKRoot}, - {"/Project/rsk/", VirtualViewRSKRoot}, - } - for _, tc := range cases { - got := ResolveVirtualView(root, tc.url) - if !got.Resolved || got.Kind != tc.want { - t.Errorf("%s: kind=%d resolved=%v; want kind=%d resolved=true", tc.url, got.Kind, got.Resolved, tc.want) - } - if got.Project != "Project" { - t.Errorf("%s: project=%q want Project", tc.url, got.Project) - } - if !got.Kind.IsRootKind() { - t.Errorf("%s: IsRootKind=false", tc.url) - } - } -} - -func TestResolveVirtualView_Specs(t *testing.T) { - root := t.TempDir() - cases := []struct { - url string - wantKind VirtualViewKind - wantBase string - }{ - {"/Project/ssr/table.yaml", VirtualViewSSRSpec, "table.yaml"}, - {"/Project/ssr/form.yaml", VirtualViewSSRSpec, "form.yaml"}, - {"/Project/mdl/table.yaml", VirtualViewMDLSpec, "table.yaml"}, - {"/Project/mdl/form.yaml", VirtualViewMDLSpec, "form.yaml"}, - {"/Project/rsk/table.yaml", VirtualViewRSKSpec, "table.yaml"}, - {"/Project/rsk/form.yaml", VirtualViewRSKSpec, "form.yaml"}, - } - for _, tc := range cases { - got := ResolveVirtualView(root, tc.url) - if !got.Resolved || got.Kind != tc.wantKind { - t.Errorf("%s: kind=%d resolved=%v; want kind=%d", tc.url, got.Kind, got.Resolved, tc.wantKind) - } - if got.SpecBase != tc.wantBase { - t.Errorf("%s: SpecBase=%q want %q", tc.url, got.SpecBase, tc.wantBase) - } - if !got.Kind.IsSpecKind() { - t.Errorf("%s: IsSpecKind=false", tc.url) - } - } -} - -func TestResolveVirtualView_SSRRow(t *testing.T) { - root := t.TempDir() - got := ResolveVirtualView(root, "/Project/ssr/0330C1.yaml") - if !got.Resolved || got.Kind != VirtualViewSSRRow { - t.Fatalf("unexpected resolution: %+v", got) - } - if got.Party != "0330C1" { - t.Errorf("Party=%q want 0330C1", got.Party) - } - wantAbs := filepath.Join(root, "Project", "archive", "0330C1", "ssr.yaml") - if got.CanonicalAbs != wantAbs { - t.Errorf("CanonicalAbs=%q want %q", got.CanonicalAbs, wantAbs) - } - wantSchema := filepath.Join(root, "Project", "archive", "0330C1", "ssr.form.yaml") - if got.SchemaAbs != wantSchema { - t.Errorf("SchemaAbs=%q want %q", got.SchemaAbs, wantSchema) - } - if !got.Kind.IsRowKind() { - t.Errorf("IsRowKind=false") - } -} - -func TestResolveVirtualView_RollupRow(t *testing.T) { - root := t.TempDir() - cases := []struct { - url string - wantKind VirtualViewKind - wantParty string - wantFilename string - wantSlot string - }{ - {"/Project/mdl/0330C1__D-001.yaml", VirtualViewMDLRow, "0330C1", "D-001.yaml", "mdl"}, - {"/Project/rsk/Acme__R-005.yaml", VirtualViewRSKRow, "Acme", "R-005.yaml", "rsk"}, - } - for _, tc := range cases { - got := ResolveVirtualView(root, tc.url) - if !got.Resolved || got.Kind != tc.wantKind { - t.Errorf("%s: kind=%d resolved=%v; want kind=%d", tc.url, got.Kind, got.Resolved, tc.wantKind) - continue - } - if got.Party != tc.wantParty { - t.Errorf("%s: Party=%q want %q", tc.url, got.Party, tc.wantParty) - } - if got.RowFilename != tc.wantFilename { - t.Errorf("%s: RowFilename=%q want %q", tc.url, got.RowFilename, tc.wantFilename) - } - wantAbs := filepath.Join(root, "Project", "archive", tc.wantParty, tc.wantSlot, tc.wantFilename) - if got.CanonicalAbs != wantAbs { - t.Errorf("%s: CanonicalAbs=%q want %q", tc.url, got.CanonicalAbs, wantAbs) - } - } -} - -func TestResolveVirtualView_NonMatches(t *testing.T) { - root := t.TempDir() - cases := []string{ - "/", - "/Project", - "/Project/", - "/Project/archive/Acme/mdl", - "/Project/ssr/invalid__name__double.yaml", // double-double underscore is rejected - "/Project/mdl/__leading.yaml", // empty party - "/Project/mdl/party__.yaml", // empty rowBase - "/Project/ssr/.hidden.yaml", // dotfile party name - "/Project/ssr/0330C1.yaml/sub", // sub-path under row file - "/Project/notaslot/table.yaml", - } - for _, url := range cases { - got := ResolveVirtualView(root, url) - if got.Resolved { - t.Errorf("%s: unexpectedly resolved as kind %d", url, got.Kind) - } - } -} - -// TestResolveVirtualView_FolderNavRoot — the project-level virtual -// folder-nav aggregators resolve to VirtualViewFolderNavRoot for the -// bare slot URL (trailing slash optional). -func TestResolveVirtualView_FolderNavRoot(t *testing.T) { - root := t.TempDir() - cases := []struct { - url string - slot string - }{ - {"/Project/working", "working"}, - {"/Project/working/", "working"}, - {"/Project/staging", "staging"}, - {"/Project/staging/", "staging"}, - {"/Project/reviewing", "reviewing"}, - {"/Project/reviewing/", "reviewing"}, - } - for _, tc := range cases { - got := ResolveVirtualView(root, tc.url) - if !got.Resolved || got.Kind != VirtualViewFolderNavRoot { - t.Errorf("%s: kind=%d resolved=%v; want FolderNavRoot resolved=true", tc.url, got.Kind, got.Resolved) - } - if got.Slot != tc.slot { - t.Errorf("%s: Slot=%q want %q", tc.url, got.Slot, tc.slot) - } - if !got.Kind.IsRootKind() { - t.Errorf("%s: IsRootKind=false", tc.url) - } - if !got.Kind.IsFolderNavKind() { - t.Errorf("%s: IsFolderNavKind=false", tc.url) - } - } -} - -// TestResolveVirtualView_FolderNavRedir — URLs deeper than the bare -// slot resolve to VirtualViewFolderNavRedir with Party + RedirRest -// populated; the dispatcher 302s these to the canonical -// archive/<party>/<slot>/<rest> path. -func TestResolveVirtualView_FolderNavRedir(t *testing.T) { - root := t.TempDir() - cases := []struct { - url string - wantParty string - wantRedirRest string - wantCanonical string - }{ - {"/Project/working/Acme", "Acme", "", "/Project/archive/Acme/working/"}, - {"/Project/working/Acme/", "Acme", "", "/Project/archive/Acme/working/"}, - {"/Project/staging/Acme/2026-05-15_X (RFI) - T", "Acme", "2026-05-15_X (RFI) - T", "/Project/archive/Acme/staging/2026-05-15_X (RFI) - T"}, - // Trailing slash is stripped at resolver entry; the dispatcher - // re-appends it before issuing the 302 to match the request shape. - {"/Project/reviewing/Acme/T-0042/", "Acme", "T-0042", "/Project/archive/Acme/reviewing/T-0042"}, - } - for _, tc := range cases { - got := ResolveVirtualView(root, tc.url) - if !got.Resolved || got.Kind != VirtualViewFolderNavRedir { - t.Errorf("%s: kind=%d resolved=%v; want FolderNavRedir resolved=true", tc.url, got.Kind, got.Resolved) - continue - } - if got.Party != tc.wantParty { - t.Errorf("%s: Party=%q want %q", tc.url, got.Party, tc.wantParty) - } - if got.RedirRest != tc.wantRedirRest { - t.Errorf("%s: RedirRest=%q want %q", tc.url, got.RedirRest, tc.wantRedirRest) - } - if got.CanonicalURL != tc.wantCanonical { - t.Errorf("%s: CanonicalURL=%q want %q", tc.url, got.CanonicalURL, tc.wantCanonical) - } - if !got.Kind.IsFolderNavKind() { - t.Errorf("%s: IsFolderNavKind=false", tc.url) - } - } -} - -// TestListPartyDirsInSlot — folder-nav listings include only parties -// whose archive/<party>/<slot>/ directory exists AND has non-empty -// content (the in-flight filter). Parties with an empty or absent -// slot directory are suppressed. -func TestListPartyDirsInSlot(t *testing.T) { - root := t.TempDir() - projectAbs := filepath.Join(root, "Project") - - // Acme has working content; Beta has only a .zddc system file - // (counts as empty); Gamma has the slot directory but it's - // completely empty; Delta doesn't have the slot at all. - if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Acme", "working"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(projectAbs, "archive", "Acme", "working", "draft.md"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Beta", "working"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(projectAbs, "archive", "Beta", "working", ".zddc"), []byte(""), 0o644); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Gamma", "working"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Delta"), 0o755); err != nil { - t.Fatal(err) - } - - got, err := ListPartyDirsInSlot(root, projectAbs, "working") - if err != nil { - t.Fatal(err) - } - want := []string{"Acme"} - if strings.Join(got, ",") != strings.Join(want, ",") { - t.Errorf("ListPartyDirsInSlot(working) = %v, want %v", got, want) - } -} - -// TestListPartyDirsInSlot_BadSlot — only the three folder-nav slots -// are valid. -func TestListPartyDirsInSlot_BadSlot(t *testing.T) { - root := t.TempDir() - for _, bad := range []string{"ssr", "mdl", "rsk", "received", "issued", "incoming", ""} { - if _, err := ListPartyDirsInSlot(root, root, bad); err == nil { - t.Errorf("expected error for slot=%q (only working/staging/reviewing valid)", bad) - } - } -} - -// TestIsPlanReviewURL — the eligibility test surfaces the X-ZDDC-On- -// Plan-Review header. Matches /<project>/archive/<party>/received/ -// <tracking>/ with or without trailing slash; everything else returns -// false. -func TestIsPlanReviewURL(t *testing.T) { - cases := []struct { - url string - want bool - }{ - {"/Project/archive/Acme/received/Acme-0042", true}, - {"/Project/archive/Acme/received/Acme-0042/", true}, - {"/Project/archive/Acme/received", false}, - {"/Project/archive/Acme/received/", false}, - {"/Project/archive/Acme/received/Acme-0042/file.pdf", false}, - {"/Project/archive/Acme/issued/Acme-0042/", false}, - {"/Project/archive/Acme", false}, - {"/Project/archive", false}, - {"/Project", false}, - {"/", false}, - {"", false}, - } - for _, tc := range cases { - if got := IsPlanReviewURL(tc.url); got != tc.want { - t.Errorf("IsPlanReviewURL(%q) = %v, want %v", tc.url, got, tc.want) - } - } -} - -func TestIsSSRCreateURL(t *testing.T) { - cases := []struct { - url string - want string - wantOK bool - }{ - {"/Project/ssr/form.html", "Project", true}, - {"/Other-Project/ssr/form.html", "Other-Project", true}, - {"/Project/ssr/", "", false}, - {"/Project/ssr/Acme.yaml.html", "", false}, - {"/Project/mdl/form.html", "", false}, - {"/.hidden/ssr/form.html", "", false}, - } - for _, tc := range cases { - got, ok := IsSSRCreateURL(tc.url) - if ok != tc.wantOK { - t.Errorf("%s: ok=%v want %v", tc.url, ok, tc.wantOK) - } - if got != tc.want { - t.Errorf("%s: project=%q want %q", tc.url, got, tc.want) - } - } -} - -func TestStripYAMLHTML(t *testing.T) { - cases := []struct { - in string - want string - wantOK bool - }{ - {"/Project/ssr/Acme.yaml.html", "/Project/ssr/Acme.yaml", true}, - {"/Project/mdl/foo__bar.yaml.html", "/Project/mdl/foo__bar.yaml", true}, - {"/Project/ssr/Acme.yaml", "/Project/ssr/Acme.yaml", false}, - {"/Project/ssr/form.html", "/Project/ssr/form.html", false}, - } - for _, tc := range cases { - got, ok := StripYAMLHTML(tc.in) - if got != tc.want || ok != tc.wantOK { - t.Errorf("%s: (%q, %v) want (%q, %v)", tc.in, got, ok, tc.want, tc.wantOK) - } - } -} - func TestValidPartyName(t *testing.T) { ok := []string{"0330C1", "Acme", "Acme.Inc", "Acme-Subsidiary", "a", "0", "0330C1.draft", "X-Y-Z"} bad := []string{"", ".hidden", "_underscore", "Acme/sub", "Acme Inc", "Acme(Inc)", "Acme,Inc", "..", "-leading-dash"} @@ -347,36 +22,117 @@ func TestValidPartyName(t *testing.T) { } } -func TestListSSRParties(t *testing.T) { - root := t.TempDir() - projectAbs := filepath.Join(root, "Project") - for _, party := range []string{"0330C1", "0440P2", "Acme"} { - if err := os.MkdirAll(filepath.Join(projectAbs, "archive", party), 0o755); err != nil { - t.Fatal(err) +func TestIsPlanReviewURL(t *testing.T) { + cases := []struct { + url string + want bool + }{ + {"/Project/archive/Acme/received/Acme-0042", true}, + {"/Project/archive/Acme/received/Acme-0042/", true}, + {"/Project/archive/Acme/received", false}, + {"/Project/archive/Acme/received/", false}, + {"/Project/archive/Acme/received/Acme-0042/file.pdf", false}, + {"/Project/archive/Acme/issued/Acme-0042/", false}, + {"/Project/archive/Acme", false}, + {"/", false}, + {"", false}, + } + for _, tc := range cases { + if got := IsPlanReviewURL(tc.url); got != tc.want { + t.Errorf("IsPlanReviewURL(%q) = %v, want %v", tc.url, got, tc.want) } } - // A file (not a dir) and a hidden folder should be filtered out. - if err := os.WriteFile(filepath.Join(projectAbs, "archive", "stray.txt"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(projectAbs, "archive", ".hidden"), 0o755); err != nil { - t.Fatal(err) - } - - parties, err := ListSSRParties(root, projectAbs) - if err != nil { - t.Fatal(err) - } - want := []string{"0330C1", "0440P2", "Acme"} - if strings.Join(parties, ",") != strings.Join(want, ",") { - t.Errorf("got %v, want %v", parties, want) - } } -func TestListSSRParties_NoArchive(t *testing.T) { +func TestIsSSRCreateURL(t *testing.T) { + cases := []struct { + url string + want string + wantOK bool + }{ + {"/Project/ssr/form.html", "Project", true}, + {"/Other-Project/ssr/form.html", "Other-Project", true}, + {"/Project/ssr/", "", false}, + {"/Project/ssr/Acme.yaml.html", "", false}, + {"/Project/mdl/form.html", "", false}, + {"/.hidden/ssr/form.html", "", false}, + } + for _, tc := range cases { + got, ok := IsSSRCreateURL(tc.url) + if ok != tc.wantOK || got != tc.want { + t.Errorf("IsSSRCreateURL(%q) = (%q,%v) want (%q,%v)", tc.url, got, ok, tc.want, tc.wantOK) + } + } +} + +func TestIsRollupCreateURL(t *testing.T) { + cases := []struct { + url string + wantProj string + wantPeer string + wantOK bool + }{ + {"/Project/mdl/form.html", "Project", "mdl", true}, + {"/Project/rsk/form.html", "Project", "rsk", true}, + {"/Project/ssr/form.html", "", "", false}, + {"/Project/mdl/Acme/form.html", "", "", false}, + {"/.hidden/mdl/form.html", "", "", false}, + } + for _, tc := range cases { + proj, peer, ok := IsRollupCreateURL(tc.url) + if ok != tc.wantOK || proj != tc.wantProj || peer != tc.wantPeer { + t.Errorf("IsRollupCreateURL(%q) = (%q,%q,%v) want (%q,%q,%v)", tc.url, proj, peer, ok, tc.wantProj, tc.wantPeer, tc.wantOK) + } + } +} + +func TestStripYAMLHTML(t *testing.T) { + cases := []struct { + in string + want string + wantOK bool + }{ + {"/Project/mdl/Acme/D-001.yaml.html", "/Project/mdl/Acme/D-001.yaml", true}, + {"/Project/ssr/Acme.yaml.html", "/Project/ssr/Acme.yaml", true}, + {"/Project/ssr/Acme.yaml", "/Project/ssr/Acme.yaml", false}, + {"/Project/ssr/form.html", "/Project/ssr/form.html", false}, + } + for _, tc := range cases { + got, ok := StripYAMLHTML(tc.in) + if got != tc.want || ok != tc.wantOK { + t.Errorf("%s: (%q, %v) want (%q, %v)", tc.in, got, ok, tc.want, tc.wantOK) + } + } +} + +// ListParties reads the registry — one ssr/<party>.yaml per registered party. +func TestListParties(t *testing.T) { root := t.TempDir() projectAbs := filepath.Join(root, "Project") - parties, err := ListSSRParties(root, projectAbs) + ssrDir := filepath.Join(projectAbs, "ssr") + if err := os.MkdirAll(ssrDir, 0o755); err != nil { + t.Fatal(err) + } + for _, party := range []string{"0330C1", "0440P2", "Acme"} { + if err := os.WriteFile(filepath.Join(ssrDir, party+".yaml"), []byte("kind: SSR\n"), 0o644); err != nil { + t.Fatal(err) + } + } + // Non-.yaml files filtered out. + _ = os.WriteFile(filepath.Join(ssrDir, "stray.txt"), []byte("x"), 0o644) + + parties, err := ListParties(projectAbs) + if err != nil { + t.Fatal(err) + } + if strings.Join(parties, ",") != "0330C1,0440P2,Acme" { + t.Errorf("ListParties = %v, want [0330C1 0440P2 Acme]", parties) + } +} + +func TestListParties_NoRegistry(t *testing.T) { + root := t.TempDir() + parties, err := ListParties(filepath.Join(root, "Project")) if err != nil { t.Fatalf("err=%v want nil", err) } @@ -385,55 +141,51 @@ func TestListSSRParties_NoArchive(t *testing.T) { } } +// ListRollupRows aggregates physical <project>/<peer>/<party>/*.yaml. func TestListRollupRows(t *testing.T) { root := t.TempDir() projectAbs := filepath.Join(root, "Project") for _, party := range []string{"0330C1", "0440P2"} { - mdlDir := filepath.Join(projectAbs, "archive", party, "mdl") - if err := os.MkdirAll(mdlDir, 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(projectAbs, "mdl", party), 0o755); err != nil { t.Fatal(err) } } - // Real rows. - if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "D-001.yaml"), []byte("id: D-001\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "D-002.yaml"), []byte("id: D-002\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0440P2", "mdl", "D-010.yaml"), []byte("id: D-010\n"), 0o644); err != nil { - t.Fatal(err) - } - // Skipped: table.yaml, form.yaml, anything containing "__". - if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "table.yaml"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "form.yaml"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "weird__name.yaml"), []byte("x"), 0o644); err != nil { - t.Fatal(err) + mustWrite := func(p string) { + if err := os.WriteFile(p, []byte("id: x\n"), 0o644); err != nil { + t.Fatal(err) + } } + mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "D-001.yaml")) + mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "D-002.yaml")) + mustWrite(filepath.Join(projectAbs, "mdl", "0440P2", "D-010.yaml")) + // Skipped: table.yaml / form.yaml specs. + mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "table.yaml")) + mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "form.yaml")) - rows, err := ListRollupRows(root, projectAbs, "mdl") + rows, err := ListRollupRows(projectAbs, "mdl") if err != nil { t.Fatal(err) } if len(rows) != 3 { t.Fatalf("got %d rows, want 3; rows=%+v", len(rows), rows) } - wantNames := []string{"0330C1__D-001.yaml", "0330C1__D-002.yaml", "0440P2__D-010.yaml"} - for i, want := range wantNames { - if rows[i].SyntheticName != want { - t.Errorf("row[%d].SyntheticName=%q want %q", i, rows[i].SyntheticName, want) + // Sorted by (party, filename); $party is the real subdir. + want := []struct{ party, file, relURL string }{ + {"0330C1", "D-001.yaml", "/Project/mdl/0330C1/D-001.yaml"}, + {"0330C1", "D-002.yaml", "/Project/mdl/0330C1/D-002.yaml"}, + {"0440P2", "D-010.yaml", "/Project/mdl/0440P2/D-010.yaml"}, + } + for i, w := range want { + if rows[i].Party != w.party || rows[i].Filename != w.file || rows[i].RelURL != w.relURL { + t.Errorf("row[%d] = %+v want party=%q file=%q rel=%q", i, rows[i], w.party, w.file, w.relURL) } } } -func TestListRollupRows_BadSlot(t *testing.T) { +func TestListRollupRows_BadPeer(t *testing.T) { root := t.TempDir() - if _, err := ListRollupRows(root, root, "ssr"); err == nil { - t.Error("expected error for slot=ssr (only mdl/rsk valid)") + if _, err := ListRollupRows(filepath.Join(root, "Project"), "ssr"); err == nil { + t.Error("expected error for peer=ssr (only mdl/rsk valid)") } } diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go index ac4a953..28f0e5e 100644 --- a/zddc/internal/zddc/walker.go +++ b/zddc/internal/zddc/walker.go @@ -88,6 +88,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile { if top.DropTarget != nil { out.DropTarget = top.DropTarget } + if top.PartySource != "" { + out.PartySource = top.PartySource + } if top.History != nil { out.History = top.History }