Completes the migration. The embedded per-depth tree (internal/zddc/defaults/)
is now the sole source of the shipped baseline; defaults.zddc.yaml is deleted.
- EmbeddedDefaults() assembles the tree (no yaml). show-defaults now emits a
.zddc.zip (per-depth, "*" wildcard members) via EmbeddedDefaultsZip() —
operators redirect it to <ROOT>/.zddc.zip (or any directory) and edit/add/
delete individual members.
- Dropped EmbeddedDefaultsBytes; reworked the dumpable test to validate the
emitted zip; removed the now-redundant tree-vs-yaml oracle (the Layer-2
matrix is the ongoing behavioral guarantee, and it stays green).
- Swept stale "defaults.zddc.yaml" comment references to the embedded tree.
- GRAMMAR.md §1/§6 updated: .zddc.zip is a policy bundle mountable at ANY
directory (subtree mount; inherit:false + acl.inherit:false = island); the
shipped baseline is the embedded bundle at the root.
Net of the 6-phase migration: policy is per-depth .zddc files in a .zddc.zip
that an operator can drop at any level to override the cascade; the engine
(Assemble + the unchanged walker) enforces it. Full Go suite + matrix green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
461 lines
17 KiB
Go
461 lines
17 KiB
Go
// 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 /<project>/ssr/form.html → "+ Add row" / SSR create
|
|
// POST /<project>/ssr/<old>.yaml with X-ZDDC-Op: ssr-rename
|
|
// X-ZDDC-Destination: /<project>/ssr/<new>.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 <project>/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
|
|
// <project>/archive/<name>/ and writes archive/<name>/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 <project>/archive/<name>/ — 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 <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 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 /<project>/(mdl|rsk)/form.html
|
|
//
|
|
// Content-Type: application/yaml | application/json
|
|
// body: { party: "<name>", ...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
|
|
// <project>/archive/<party>/<slot>/, same chain the generic
|
|
// serveFormCreate would gate against if the user were on the
|
|
// per-party path directly.
|
|
//
|
|
// On success: 201 + Location: /<project>/<slot>/<party>__<filename>.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/<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: "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 <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
|
|
}
|
|
|
|
// 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 <party>/<slot>)
|
|
// + 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 /<project>/ssr/<old>.yaml
|
|
//
|
|
// X-ZDDC-Op: ssr-rename
|
|
// X-ZDDC-Destination: /<project>/ssr/<new>.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 <project>/archive/<old>/ AND ActionCreate at
|
|
// <project>/archive/<new>/. Both gates evaluate against their canonical
|
|
// archive paths so non-owners can't rename someone else's party.
|
|
//
|
|
// On success: os.Rename moves archive/<old>/ → archive/<new>/ (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 /<project>/ssr/<party>.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 /<project>/ssr/<party>.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/<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
|
|
}
|
|
|
|
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 /<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
|
|
}
|