// Package handler — ssrhandler.go: Supplier/Subcontractor Status Report // lifecycle endpoints that don't fit the generic form / file API shapes. // // Two endpoints live here: // // POST //ssr/form.html → "+ Add row" / SSR create // POST //ssr/.yaml with X-ZDDC-Op: ssr-rename // X-ZDDC-Destination: //ssr/.yaml // // Both target the project-level SSR aggregator, which is a virtual view // (see zddc/virtualviews.go). Internally they materialize / rename real // party folders under /archive/. // // The generic file API only does file-level moves; renaming a party // folder means renaming a directory and bringing every row inside with // it. Rather than weaken serveFileMove's IsDir-source guard (which keeps // callers from accidentally moving entire subtrees), this file // implements ssr-rename as a tightly-scoped op that only fires for the // SSR virtual URL shape. package handler import ( "encoding/json" "errors" "fmt" "log/slog" "net/http" "net/url" "os" "path/filepath" "strings" "time" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "gopkg.in/yaml.v3" ) // opSSRRename is the X-ZDDC-Op value that fires serveSSRRename. Dispatched // from serveFilePost in fileapi.go. const opSSRRename = "ssr-rename" // serveFormCreateSSR materializes a new party folder under // /archive// and writes archive//ssr.yaml from the // submitted form body. The `name` field doubles as the party folder // name and is stripped from the YAML before write (path-derived). // // ACL gate: ActionCreate at /archive// — typically // satisfied by document_controller's rwc grant on archive/ in the // project-level cascade, OR by a deployment that grants `c` to a wider // audience. // // Auto-own .zddc is seeded for the new party folder via // WriteAutoOwnZddc, the same machinery the generic mkdir uses. // Authenticated email is required (401 otherwise) so the auto-own // grant always names a real principal. func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) { email := EmailFromContext(r) if email == "" { http.Error(w, "authentication required", http.StatusUnauthorized) return } if req.Project == "" { http.Error(w, "internal: SSR create missing project", http.StatusInternalServerError) return } data, err := decodeRequestData(r) if err != nil { http.Error(w, "request body: "+err.Error(), http.StatusBadRequest) return } spec, err := loadFormSpec(cfg.Root, req.SpecPath) if err != nil { http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError) return } if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 { writeValidationErrors(w, errs) return } dataMap, ok := data.(map[string]interface{}) if !ok { http.Error(w, "request body must be a YAML/JSON object", http.StatusBadRequest) return } nameRaw, _ := dataMap["name"].(string) name := strings.TrimSpace(nameRaw) if !zddc.ValidPartyName(name) { writeValidationErrors(w, []jsonschema.Error{{ Path: "/name", Message: "must match " + `^[A-Za-z0-9][A-Za-z0-9.-]*$`, }}) return } 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 } // ACL gate: create the registry row at /ssr/.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 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 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) } // 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) if err != nil { auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, 0, err) http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError) return } // Route through WriteWithHistory so audit fields (created_*, // updated_*, revision=1) are stamped uniformly with the PUT // path. No prior file exists, so the history-write branch is a // no-op — only the stamping + live write fire. res, verrs, herr := WriteWithHistory(cfg, yamlAbs, rowURL, yamlBytes, email) if herr != nil { auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, len(yamlBytes), herr) http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError) return } if len(verrs) > 0 { writeValidationErrors(w, verrs) return } finalBody := res.FinalBody w.Header().Set("Location", rowURL) w.Header().Set("Content-Type", "application/json") w.Header().Set("X-ZDDC-Source", "ssr-create") w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL))) auditFile(r, "ssr-create", rowURL, http.StatusCreated, len(finalBody), nil) } // serveFormCreateRollup adds a row to a project-level MDL or RSK // rollup view by writing it inside the per-party folder named by the // submitted `party` field. // // Wire-form: POST //(mdl|rsk)/form.html // // Content-Type: application/yaml | application/json // body: { party: "", ...row fields... } // // The rollup form schema (default-project-{mdl,rsk}.form.yaml) makes // `party` a required field; the rollup view's `Package` column maps // to it. The party folder must already exist — create it via the // SSR view first. ACL gate: ActionCreate at // /archive///, same chain the generic // serveFormCreate would gate against if the user were on the // per-party path directly. // // On success: 201 + Location: ///__.yaml, // the virtual row URL that the rollup table client uses to address // the new row. func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) { email := EmailFromContext(r) if email == "" { http.Error(w, "authentication required", http.StatusUnauthorized) return } if req.Project == "" || (req.Slot != "mdl" && req.Slot != "rsk") { http.Error(w, "internal: rollup create missing project/slot", http.StatusInternalServerError) return } data, err := decodeRequestData(r) if err != nil { http.Error(w, "request body: "+err.Error(), http.StatusBadRequest) return } spec, err := loadFormSpec(cfg.Root, req.SpecPath) if err != nil { http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError) return } if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 { writeValidationErrors(w, errs) return } dataMap, ok := data.(map[string]interface{}) if !ok { http.Error(w, "request body must be a YAML/JSON object", http.StatusBadRequest) return } partyRaw, _ := dataMap["party"].(string) party := strings.TrimSpace(partyRaw) if !zddc.ValidPartyName(party) { writeValidationErrors(w, []jsonschema.Error{{ Path: "/party", Message: "must match " + `^[A-Za-z0-9][A-Za-z0-9.-]*$`, }}) return } // The party must be registered (ssr/.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: "unknown party — register it via the SSR view first", }}) return } 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 ///. 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 } // Strip the routing key from the data before write — the folder // name IS the identity and the per-party MDL/RSK schemas forbid // `additionalProperties` other than the listed ones. delete(dataMap, "party") if err := os.MkdirAll(slotAbs, 0o755); err != nil { auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // Resolve the cascade rule at slotAbs to pick a composed filename. // The internal/zddc/defaults/ records: entries declare a "*.yaml" rule // for both mdl/ and rsk/ folders with filename_format pointing at // body fields; for RSK, the rule also carries row_field + // row_scope_fields so the server can assign the next row sequence // within the table-tracking group. chain, err := zddc.EffectivePolicy(cfg.Root, slotAbs) if err != nil { auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err) http.Error(w, "cascade resolve: "+err.Error(), http.StatusInternalServerError) return } // Probe with the wildcard placeholder; the embedded defaults // declare a "*.yaml" entry for both slots. _, rule, hasRule := chain.EffectiveRecordRule("placeholder.yaml") var target, fname string if hasRule && rule.FilenameFormat != "" { // Shared record-create prep: field_defaults + folder_fields // (originator = party folder, since slotAbs is /) // + per-row sequence + filename composition. WriteWithHistory // below re-applies these as the authority. var composeErr *jsonschema.Error fname, composeErr, err = recordCreatePrep(cfg, slotAbs, rule, dataMap) if err != nil { auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err) http.Error(w, "record prep: "+err.Error(), http.StatusInternalServerError) return } if composeErr != nil { writeValidationErrors(w, []jsonschema.Error{*composeErr}) return } target = filepath.Join(slotAbs, fname) if _, err := os.Stat(target); err == nil { http.Error(w, "Conflict — a row with this composed tracking number already exists", http.StatusConflict) return } } else { // Fallback for deployments that override the embedded // defaults without providing records: entries — keep the // historical date+email naming so they aren't broken by // this upgrade. dateStr := time.Now().UTC().Format("2006-01-02") emailSan := sanitizeEmail(email) base := dateStr + "-" + emailSan var ok bool target, fname, ok = pickAvailableFilename(slotAbs, base, ".yaml") if !ok { http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict) return } } yamlBytes, err := yaml.Marshal(dataMap) if err != nil { auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err) http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError) return } // 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) if herr != nil { auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, len(yamlBytes), herr) http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError) return } if len(verrs) > 0 { writeValidationErrors(w, verrs) return } finalBody := res.FinalBody 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") w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL))) auditFile(r, "rollup-create", rowURL, http.StatusCreated, len(finalBody), nil) } // serveSSRRename renames a party folder by rewriting an SSR row URL. // // Wire-form: POST //ssr/.yaml // // X-ZDDC-Op: ssr-rename // X-ZDDC-Destination: //ssr/.yaml // // Both source and destination must resolve as SSR-row virtual URLs in // the same project. The destination party name must be a valid party // folder name, must differ from the source, and must not already exist. // // ACL: ActionWrite at /archive// AND ActionCreate at // /archive//. Both gates evaluate against their canonical // archive paths so non-owners can't rename someone else's party. // // On success: os.Rename moves archive// → archive// (an // atomic directory rename on the same filesystem); every row inside // follows. No denormalized `party:` field rewrite is needed — the // MDL/RSK schemas don't carry one. func serveSSRRename(cfg config.Config, w http.ResponseWriter, r *http.Request) { email := EmailFromContext(r) if email == "" { http.Error(w, "authentication required", http.StatusUnauthorized) return } srcProject, srcParty, ok := parseSSRRowURL(r.URL.Path) if !ok { http.Error(w, "Bad Request — ssr-rename source must be //ssr/.yaml", http.StatusBadRequest) return } dstHeader := r.Header.Get(headerDestination) if dstHeader == "" { http.Error(w, "Bad Request — missing "+headerDestination+" header", http.StatusBadRequest) return } if dec, err := url.PathUnescape(dstHeader); err == nil { dstHeader = dec } dstProject, dstParty, ok := parseSSRRowURL(dstHeader) if !ok { http.Error(w, "Bad Request — ssr-rename destination must be //ssr/.yaml", http.StatusBadRequest) return } if dstProject != srcProject { http.Error(w, "Bad Request — ssr-rename cannot cross projects", http.StatusBadRequest) return } if dstParty == srcParty { http.Error(w, "Bad Request — destination is the same as source", http.StatusBadRequest) 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 } 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 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, dstAbs, dstURL, policy.ActionCreate) { return } if !checkIfMatch(w, r, srcAbs) { return } // Registry-only rename: moves the ssr/.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 } 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": dstURL}) _, _ = w.Write(resp) auditFile(r, "ssr-rename", r.URL.Path, http.StatusOK, 0, nil) } // parseSSRRowURL parses //ssr/.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 }