feat: per-party RSK + project-level SSR/MDL/RSK rollup tables

Adds the risk register as a sibling of MDL under archive/<party>/, and
three project-level virtual aggregations at <project>/{ssr,mdl,rsk}:

  - SSR aggregates archive/<party>/ssr.yaml; "+ Add row" materializes a
    new party folder (mkdir + auto-own .zddc + ssr.yaml). Renames go
    through X-ZDDC-Op: ssr-rename, which os.Rename's the party
    directory so every row inside follows. Party name doubles as the
    folder name (no opaque IDs) and is path-derived on read.

  - MDL/RSK rollups list every deliverable / every risk across all
    parties with a derived `party` column; "+ Add row" is suppressed
    because party affiliation is ambiguous in the aggregate view.

All four virtual roots are declared `virtual: true` in
defaults.zddc.yaml. Spec/form bytes come from six new embedded
defaults (default-rsk.*, default-ssr.*, default-project-{mdl,rsk}.*)
served via a generalized IsDefaultSpec/IsDefaultSpecAbs that replaces
the MDL-only recognizer. Listing synthesis lives in fs/tree.go;
ACL on each synthetic row evaluates against the canonical
archive/<party>/ chain so non-owners see rows read-only. PUT/DELETE
through virtual URLs rewrite to canonical paths in fileapi.go via
sibling-shape blocks that don't touch the ACL gate. SSR row DELETE
returns 405 (delete the party folder via the archive view).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-18 21:47:56 -05:00
parent 351dc63cb4
commit 73e34bed5e
20 changed files with 1540 additions and 118 deletions

View file

@ -111,6 +111,11 @@
description: spec.description, description: spec.description,
columns: spec.columns, columns: spec.columns,
defaults: spec.defaults, defaults: spec.defaults,
// addable defaults to true; tables can opt out with
// `addable: false` (used by project-rollup MDL/RSK where the
// party affiliation of a new row is ambiguous — add at the
// per-party path instead).
addable: spec.addable !== false,
rowSchema: rowSchema, rowSchema: rowSchema,
rows: rows rows: rows
}; };

View file

@ -41,7 +41,11 @@
if (addRowBtn) { if (addRowBtn) {
const onHttp = location.protocol === 'http:' || location.protocol === 'https:'; const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0; const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
if (onHttp && hasCols) { // ctx.addable === false suppresses the affordance entirely.
// Used by project-rollup tables where the row's party
// affiliation is ambiguous (add at the per-party path).
const allowAdd = ctx.addable !== false;
if (onHttp && hasCols && allowAdd) {
addRowBtn.hidden = false; addRowBtn.hidden = false;
addRowBtn.removeAttribute('href'); addRowBtn.removeAttribute('href');
addRowBtn.setAttribute('role', 'button'); addRowBtn.setAttribute('role', 'button');

View file

@ -1002,13 +1002,20 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
info, err := os.Stat(absPath) info, err := os.Stat(absPath)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
// Default MDL spec fallback: archive/<party>/mdl.table.yaml // Default-spec fallback for the embedded table.yaml / form.yaml
// and archive/<party>/mdl.form.yaml are served from embedded // files served when no operator file exists on disk:
// bytes when no operator file exists on disk. The table app //
// fetches these client-side; the fallback lets a fresh // <project>/archive/<party>/{mdl,rsk}/{table,form}.yaml
// project work out of the box. // <project>/archive/<party>/ssr.form.yaml
// <project>/{ssr,mdl,rsk}/{table,form}.yaml
//
// The table app fetches these client-side; the fallback lets
// a fresh project work out of the box. ACL gates against the
// chain at the request directory; for project-level virtual
// specs that chain is the project's, and for per-party paths
// it's the party's archive folder.
if r.Method == http.MethodGet || r.Method == http.MethodHead { if r.Method == http.MethodGet || r.Method == http.MethodHead {
if bytes, ok := handler.IsDefaultMdlSpec(cfg.Root, urlPath); ok { if bytes, ok := handler.IsDefaultSpec(cfg.Root, urlPath); ok {
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath)) chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
@ -1016,7 +1023,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
} }
w.Header().Set("Content-Type", "application/yaml; charset=utf-8") w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-ZDDC-Source", "default-mdl-spec") w.Header().Set("X-ZDDC-Source", "default-spec")
if r.Method == http.MethodHead { if r.Method == http.MethodHead {
return return
} }
@ -1024,6 +1031,22 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return 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 <project>/archive/<party>/.
// 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
}
}
// File doesn't exist at this path. Before falling through to // File doesn't exist at this path. Before falling through to
// app-HTML routing or 404, check the two virtual-file-extension // app-HTML routing or 404, check the two virtual-file-extension
// shapes that ZDDC exposes through the listing convention: // shapes that ZDDC exposes through the listing convention:

View file

@ -212,6 +212,68 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
// to real ones. // to real ones.
result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...) result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...)
// Project-level virtual table views: SSR aggregates one row per
// party folder under archive/; MDL/RSK rollups aggregate every
// row from each party's mdl/ or rsk/. The listing surfaces
// synthetic row entries (Writable bit per the canonical
// archive/<party>/ 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 are served by the
// main.go IsDefaultSpec fallback; row reads go through
// handler.ServeVirtualViewRow which path-injects name/party.
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)
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, rowURL); !allowed {
return
}
partyActiveAdmin := elevated && userEmail != "" &&
zddc.IsAdminForChain(chain, userEmail)
writable := partyActiveAdmin
if !writable {
allowed, _ := policy.AllowActionFromChainP(ctx, decider, chain, principal, rowURL, policy.ActionWrite)
writable = allowed
}
result = append(result, listing.FileInfo{
Name: syntheticName,
URL: rowURL,
IsDir: false,
Virtual: true,
Writable: writable,
})
}
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)
for _, row := range rows {
partyAbs := filepath.Join(vv.ProjectAbs, "archive", row.Party)
appendVirtualRow(row.SyntheticName, partyAbs)
}
}
result = append(result,
listing.FileInfo{Name: "table.yaml", URL: baseURL + "table.yaml", IsDir: false, Virtual: true},
listing.FileInfo{Name: "form.yaml", URL: baseURL + "form.yaml", IsDir: false, Virtual: true},
)
}
// Workflow folder: append a virtual `received/` entry whose backing // Workflow folder: append a virtual `received/` entry whose backing
// is .zddc.received_path. The entry's URL stays under the workflow // is .zddc.received_path. The entry's URL stays under the workflow
// folder (baseURL + "received/") so a click navigates "into" the // folder (baseURL + "received/") so a click navigates "into" the

View file

@ -0,0 +1,69 @@
# Default project-rollup Master Deliverables List spec, served by
# zddc-server when no operator-supplied table.yaml exists at
# <project>/mdl/.
#
# This view aggregates every deliverable row from every party under
# <project>/archive/. Each synthetic row is backed by the real file
# at <project>/archive/<party>/mdl/<file>.yaml; the leading `party`
# column is derived from the row's source folder (path-injected by
# the server, not stored in the YAML).
#
# + Add row is suppressed in this view because the party affiliation
# would be ambiguous — add deliverables at the per-party path
# (<project>/archive/<party>/mdl/) and they'll appear here on next
# load.
title: Project Deliverables (all parties)
description: Every deliverable across all parties under archive/. Click a row to edit; add rows at the per-party MDL view.
addable: false
columns:
- field: party
title: Package
width: 7em
- field: originator
title: Originator
width: 8em
- field: phase
title: Phase
width: 5em
- field: project
title: Project
width: 8em
- field: area
title: Area
width: 5em
- field: discipline
title: Disc.
width: 5em
- field: type
title: Type
width: 6em
- field: sequence
title: Seq.
width: 5em
- field: suffix
title: Suffix
width: 5em
- field: title
title: Deliverable
- field: plannedRevision
title: Rev.
width: 5em
- field: plannedDate
title: Planned
format: date
width: 8em
- field: status
title: Status
width: 6em
enum: [DFT, IFR, IFA, IFC, AFC, AB]
- field: owner
title: Owner
width: 12em
defaults:
sort:
- { field: party, dir: asc }
- { field: plannedDate, dir: asc }

View file

@ -0,0 +1,56 @@
# Default project-rollup Risk Register spec, served by zddc-server
# when no operator-supplied table.yaml exists at <project>/rsk/.
#
# This view aggregates every risk row from every party under
# <project>/archive/. Each synthetic row is backed by the real file
# at <project>/archive/<party>/rsk/<file>.yaml; the leading `party`
# column is derived from the row's source folder (path-injected by
# the server, not stored in the YAML).
#
# + Add row is suppressed in this view because the party affiliation
# would be ambiguous — add risks at the per-party path
# (<project>/archive/<party>/rsk/) and they'll appear here on next
# load.
title: Project Risk Register (all parties)
description: Every risk across all parties under archive/. Click a row to edit; add rows at the per-party RSK view.
addable: false
columns:
- field: party
title: Package
width: 7em
- field: id
title: ID
width: 6em
- field: title
title: Risk
- field: category
title: Category
width: 10em
- field: likelihood
title: L
width: 4em
- field: impact
title: I
width: 4em
- field: severity
title: Sev
width: 5em
- field: owner
title: Owner
width: 12em
- field: status
title: Status
width: 9em
enum: [open, mitigated, accepted, closed]
- field: dueDate
title: Due
format: date
width: 8em
defaults:
sort:
- { field: severity, dir: desc }
- { field: party, dir: asc }

View file

@ -0,0 +1,83 @@
# Default row schema for a Risk Register entry, served by
# zddc-server when no operator-supplied form.yaml exists at
# archive/<party>/rsk/.
#
# Likelihood and impact use the standard 1-5 ordinal scales;
# severity is also 1-25 (typically L*I) and stored on each row so
# operators can override it when the simple product doesn't capture
# the actual risk profile.
#
# To customize: drop your own form.yaml into archive/<party>/rsk/
# (the same directory as table.yaml). Tighten constraints with
# `enum:`, `pattern:`, etc. Add fields and they'll appear in the
# row-edit form; add a matching column to table.yaml to surface
# the field in the table view too.
title: Risk
description: One identified risk. Likelihood and impact use 1-5 ordinals; severity is stored separately so it can be overridden when L*I underrepresents the residual exposure.
schema:
type: object
required: [id, title]
additionalProperties: false
properties:
id:
type: string
title: ID
description: Stable identifier, e.g. R-001.
minLength: 1
title:
type: string
title: Risk
minLength: 1
category:
type: string
title: Category
description: Free-form grouping (schedule, cost, technical, regulatory, ...).
description:
type: string
title: Description
likelihood:
type: integer
title: Likelihood
description: 1 (rare) to 5 (almost certain).
minimum: 1
maximum: 5
impact:
type: integer
title: Impact
description: 1 (negligible) to 5 (catastrophic).
minimum: 1
maximum: 5
severity:
type: integer
title: Severity
description: Residual risk score. Typically likelihood * impact (1-25), but operators can override.
minimum: 1
maximum: 25
mitigation:
type: string
title: Mitigation
description: Plan for reducing this risk's likelihood or impact.
owner:
type: string
title: Owner
description: Email or party name responsible for tracking this risk.
status:
type: string
title: Status
enum: [open, mitigated, accepted, closed]
dueDate:
type: string
title: Due date
format: date
notes:
type: string
title: Notes
ui:
description:
ui:widget: textarea
mitigation:
ui:widget: textarea
notes:
ui:widget: textarea

View file

@ -0,0 +1,51 @@
# Default Risk Register spec, served by zddc-server when no
# operator-supplied table.yaml exists at archive/<party>/rsk/.
#
# Columns cover the standard risk-register fields: identifier, title,
# category, likelihood / impact / severity scores, owner, status, and
# due date. Severity is stored on each row (1-25, typically L*I) so
# operators can override it when the simple product doesn't capture
# the actual risk profile.
#
# To customize: drop your own table.yaml + form.yaml into the same
# directory (archive/<party>/rsk/). The whole directory IS the table —
# spec, row-edit form, and rows are siblings. Override examples mirror
# the MDL table.yaml customization patterns.
title: Risk Register
description: Risks tracked for this party. Severity is the residual risk score; sort defaults to severity descending.
columns:
- field: id
title: ID
width: 6em
- field: title
title: Risk
- field: category
title: Category
width: 10em
- field: likelihood
title: L
width: 4em
- field: impact
title: I
width: 4em
- field: severity
title: Sev
width: 5em
- field: owner
title: Owner
width: 12em
- field: status
title: Status
width: 9em
enum: [open, mitigated, accepted, closed]
- field: dueDate
title: Due
format: date
width: 8em
defaults:
sort:
- { field: severity, dir: desc }
- { field: dueDate, dir: asc }

View file

@ -0,0 +1,76 @@
# Default row schema for a Supplier / Subcontractor Status Report
# entry, served by zddc-server when no operator-supplied form.yaml
# exists at <project>/archive/<party>/ssr.form.yaml.
#
# The `name` field doubles as the party folder name (the row's
# stable identifier). It's required on create (+ Add row materializes
# <project>/archive/<name>/) but is stripped from the YAML on save —
# the folder name IS the identity, so storing it inside the file too
# would just be a denormalization. On read the dispatcher injects
# name back into the row data so this form (and the SSR table)
# can display it.
#
# Pattern excludes leading `.` and `_` to avoid colliding with
# fileapi.go's dot/underscore-prefix guards on file paths.
#
# To customize: drop your own form.yaml into
# <project>/archive/<party>/ (sibling to the party's ssr.yaml).
title: Supplier / Subcontractor Status
description: One party's status report. The party name doubles as the archive folder name and is required when creating a new row.
schema:
type: object
required: [name, vendorType, contractNo, scopeSummary]
additionalProperties: false
properties:
name:
type: string
title: Party (folder name)
description: Becomes <project>/archive/<name>/. Typical naming = MasterFormat 4-digit code + C|P + sequence digit (e.g. 0330C1).
pattern: "^[A-Za-z0-9][A-Za-z0-9.-]*$"
minLength: 1
vendorType:
type: string
title: Vendor type
enum: [subcontractor, supplier, consultant, vendor, other]
contractNo:
type: string
title: Contract / PO number
scopeSummary:
type: string
title: Scope summary
contractValue:
type: number
title: Contract value
awardDate:
type: string
title: Award date
format: date
kickoffDate:
type: string
title: Kickoff date
format: date
scheduleStatus:
type: string
title: Schedule status
enum: [on-track, at-risk, behind, completed, on-hold]
deliverablesStatus:
type: string
title: Deliverables status
enum: [on-track, at-risk, behind, completed]
paymentStatus:
type: string
title: Payment status
enum: [current, overdue, hold, complete]
ownerContact:
type: string
title: Owner contact (email)
notes:
type: string
title: Notes
ui:
scopeSummary:
ui:widget: textarea
notes:
ui:widget: textarea

View file

@ -0,0 +1,62 @@
# Default Supplier / Subcontractor Status Report spec, served by
# zddc-server when no operator-supplied table.yaml exists at
# <project>/ssr/.
#
# The SSR is a project-level aggregation: one row per party folder
# under <project>/archive/, each row backed by
# <project>/archive/<party>/ssr.yaml. The synthetic `name` column
# shows the party folder name (which is the row's stable identifier);
# typical naming encodes a MasterFormat 4-digit code plus C|P plus
# a sequence digit (e.g. 0330C1, 0440P2).
#
# To customize: drop your own table.yaml + form.yaml at
# <project>/ssr/table.yaml + form.yaml (the cascade declares
# <project>/ssr/ as virtual, but the spec files themselves can be
# real overrides). Add columns or tighten enums as your project's
# subcontract reporting requires.
title: Supplier / Subcontractor Status
description: One row per party folder under archive/. Click + Add row to create a new party (folder + metadata).
columns:
- field: name
title: Party
width: 8em
- field: vendorType
title: Type
width: 9em
- field: contractNo
title: Contract
width: 10em
- field: scopeSummary
title: Scope
- field: contractValue
title: Value
width: 10em
- field: awardDate
title: Award
format: date
width: 8em
- field: kickoffDate
title: Kickoff
format: date
width: 8em
- field: scheduleStatus
title: Schedule
width: 9em
enum: [on-track, at-risk, behind, completed, on-hold]
- field: deliverablesStatus
title: Deliv.
width: 9em
enum: [on-track, at-risk, behind, completed]
- field: paymentStatus
title: Pmt.
width: 8em
enum: [current, overdue, hold, complete]
- field: ownerContact
title: Owner contact
width: 14em
defaults:
sort:
- { field: name, dir: asc }

View file

@ -50,6 +50,9 @@ const (
opMove = "move" opMove = "move"
opMkdir = "mkdir" opMkdir = "mkdir"
// opSSRRename / opPlanReview / opAcceptTransmittal are declared
// alongside their handler files. Listed in the dispatch switch
// below so they're discoverable from a single place.
) )
// IsWriteMethod reports whether this method is handled by the file API. // IsWriteMethod reports whether this method is handled by the file API.
@ -297,6 +300,22 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return 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)
}
// Virtual received/ rewrite. When the PUT targets a file under the // Virtual received/ rewrite. When the PUT targets a file under the
// synthetic <workflow>/received/<file> URL, the canonical record is // synthetic <workflow>/received/<file> URL, the canonical record is
// WORM — we can't write there. Convention: treat the drop as a // WORM — we can't write there. Convention: treat the drop as a
@ -424,6 +443,21 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
return 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)
}
info, err := os.Stat(abs) info, err := os.Stat(abs)
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
@ -473,6 +507,8 @@ func serveFilePost(cfg config.Config, w http.ResponseWriter, r *http.Request) {
servePlanReview(cfg, w, r) servePlanReview(cfg, w, r)
case opAcceptTransmittal: case opAcceptTransmittal:
serveAcceptTransmittal(cfg, w, r) serveAcceptTransmittal(cfg, w, r)
case opSSRRename:
serveSSRRename(cfg, w, r)
case "": case "":
http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest) http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest)
default: default:

View file

@ -77,16 +77,21 @@ type formContext struct {
// FormRequest describes a recognized form-system request. // FormRequest describes a recognized form-system request.
type FormRequest struct { type FormRequest struct {
// Kind is one of: "render-empty", "create", "render-edit", "update". // Kind is one of: "render-empty", "create", "render-edit", "update",
// or "create-via-ssr" (the special SSR create flow which materializes
// a new party folder + ssr.yaml).
Kind string Kind string
// SpecPath is the absolute filesystem path to the <name>.form.yaml. // SpecPath is the absolute filesystem path to the <name>.form.yaml.
SpecPath string SpecPath string
// DataPath is the absolute filesystem path to the data .yaml; empty for // DataPath is the absolute filesystem path to the data .yaml; empty for
// render-empty / create. // render-empty / create / create-via-ssr.
DataPath string DataPath string
// SubmitURL is the URL the form should POST back to (the server-injected // SubmitURL is the URL the form should POST back to (the server-injected
// "submit to my own URL" value). // "submit to my own URL" value).
SubmitURL string SubmitURL string
// Project carries the project name for create-via-ssr requests. Empty
// for all other kinds.
Project string
} }
// RecognizeFormRequest classifies r as a form-system request, or returns nil // RecognizeFormRequest classifies r as a form-system request, or returns nil
@ -103,17 +108,38 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
if !strings.HasSuffix(urlPath, ".html") { if !strings.HasSuffix(urlPath, ".html") {
return nil return nil
} }
// SSR create: /<project>/ssr/form.html maps to the special create
// path that materializes a new party folder (mkdir archive/<name>/)
// AND writes archive/<name>/ssr.yaml. Recognized before the generic
// form.html branch so it doesn't get misrouted as an in-dir create.
if project, ok := zddc.IsSSRCreateURL(urlPath); ok {
kind := "render-empty"
if method == http.MethodPost {
kind = "create-via-ssr"
}
// SpecPath is the embedded default SSR form schema; the loader
// falls back to embedded bytes via IsDefaultSpecAbs. The path
// itself is the virtual <project>/ssr/form.yaml location.
specAbs := filepath.Join(fsRoot, project, "ssr", "form.yaml")
return &FormRequest{
Kind: kind,
SpecPath: specAbs,
SubmitURL: urlPath,
Project: project,
}
}
underlying := strings.TrimSuffix(urlPath, ".html") underlying := strings.TrimSuffix(urlPath, ".html")
// specEligible accepts a spec path that exists on disk OR matches // specEligible accepts a spec path that exists on disk OR matches
// the default-MDL virtual-fallback shape at archive/<party>/mdl/. // any of the default-spec virtual-fallback shapes (per-party
// Without this, the default-MDL row form would 404 on a fresh // mdl/rsk, per-party SSR schema, project-level virtual specs).
// archive even though the table view renders.
specEligible := func(specAbs string) bool { specEligible := func(specAbs string) bool {
if fileExists(specAbs) { if fileExists(specAbs) {
return true return true
} }
if _, ok := IsDefaultMdlSpecAbs(fsRoot, specAbs); ok { if _, ok := IsDefaultSpecAbs(fsRoot, specAbs); ok {
return true return true
} }
return false return false
@ -154,7 +180,36 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
if strings.HasSuffix(underlying, ".yaml") { if strings.HasSuffix(underlying, ".yaml") {
// /<dir>/<id>.yaml.html — re-edit / update. Spec lives in the // /<dir>/<id>.yaml.html — re-edit / update. Spec lives in the
// SAME directory as the row file (<dir>/form.yaml). // 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,
}
}
dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/"))) dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/")))
dataAbs := filepath.Join(fsRoot, dataRel) dataAbs := filepath.Join(fsRoot, dataRel)
if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot { if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot {
@ -192,6 +247,8 @@ func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *ht
serveFormCreate(cfg, req, w, r) serveFormCreate(cfg, req, w, r)
case "update": case "update":
serveFormUpdate(cfg, req, w, r) serveFormUpdate(cfg, req, w, r)
case "create-via-ssr":
serveFormCreateSSR(cfg, req, w, r)
default: default:
http.Error(w, "unknown form request kind", http.StatusInternalServerError) http.Error(w, "unknown form request kind", http.StatusInternalServerError)
} }
@ -417,13 +474,13 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
func loadFormSpec(fsRoot, path string) (*FormSpec, error) { func loadFormSpec(fsRoot, path string) (*FormSpec, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
// Default-MDL virtual fallback: when the operator hasn't placed // Default-spec virtual fallback: when no operator file exists at
// an mdl.form.yaml under archive/<party>/, serve the embedded // path, serve the embedded default if path matches one of the
// default. Mirrors the static-handler fallback for direct YAML // recognized virtual fallback shapes (per-party mdl/rsk, per-
// fetches so the form recognizer and the loader agree on what // party SSR schema, project-level virtual specs). Mirrors the
// "this spec exists" means. // static-handler fallback for direct YAML fetches.
if os.IsNotExist(err) { if os.IsNotExist(err) {
if bytes, ok := IsDefaultMdlSpecAbs(fsRoot, path); ok { if bytes, ok := IsDefaultSpecAbs(fsRoot, path); ok {
data = bytes data = bytes
} else { } else {
return nil, err return nil, err

View file

@ -0,0 +1,287 @@
// 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"
"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
}
partyAbs := filepath.Join(cfg.Root, req.Project, "archive", name)
if !strings.HasPrefix(partyAbs, 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) {
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)
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")
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)) {
var werr error
if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) {
werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email)
} else {
werr = zddc.WriteAutoOwnZddc(partyAbs, email)
}
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.
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
}
if err := zddc.WriteAtomic(yamlAbs, yamlBytes); err != nil {
auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, len(yamlBytes), err)
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
return
}
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(yamlBytes), 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
}
src := zddc.ResolveVirtualView(cfg.Root, r.URL.Path)
if !src.Resolved || src.Kind != zddc.VirtualViewSSRRow {
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
}
dst := zddc.ResolveVirtualView(cfg.Root, dstHeader)
if !dst.Resolved || dst.Kind != zddc.VirtualViewSSRRow {
http.Error(w, "Bad Request — ssr-rename destination must be /<project>/ssr/<party>.yaml", http.StatusBadRequest)
return
}
if dst.Project != src.Project {
http.Error(w, "Bad Request — ssr-rename cannot cross projects", http.StatusBadRequest)
return
}
if dst.Party == src.Party {
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
}
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)
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) {
return
}
if !authorizeAction(cfg, w, r, dstArchive, dstArchiveURL, policy.ActionCreate) {
return
}
// Optional If-Match against the source ssr.yaml etag.
if !checkIfMatch(w, r, src.CanonicalAbs) {
return
}
if err := os.Rename(srcArchive, dstArchive); 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("X-ZDDC-Source", "ssr-rename")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
resp, _ := json.Marshal(map[string]string{"location": newURL})
_, _ = w.Write(resp)
auditFile(r, "ssr-rename", r.URL.Path, http.StatusOK, 0, nil)
}

View file

@ -0,0 +1,214 @@
package handler
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// ssrTestSetup builds a fresh project root with permissive top-level
// ACL that lets *@example.com create + write anywhere under archive/.
// Returns (cfg, do) where do dispatches a request through the same
// recognize→serve path the production catch-all uses.
func ssrTestSetup(t *testing.T) (config.Config, func(method, target, email, body string, headers map[string]string) *httptest.ResponseRecorder) {
t.Helper()
root := t.TempDir()
// Project root: grant the test cohort rwc at the project level so
// they can create archive/<party>/ folders.
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 0o644); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
do := func(method, target, email, body string, headers map[string]string) *httptest.ResponseRecorder {
var req *http.Request
if body != "" {
req = httptest.NewRequest(method, target, bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
for k, v := range headers {
req.Header.Set(k, v)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
// SSR create flows through RecognizeFormRequest → ServeForm →
// create-via-ssr case. Rename flows through ServeFileAPI's POST
// dispatch (ssr-rename op).
if method == http.MethodPost && strings.Contains(target, "/ssr/") &&
strings.HasSuffix(target, ".yaml") {
ServeFileAPI(cfg, rec, req)
return rec
}
formReq := RecognizeFormRequest(cfg.Root, method, target)
if formReq != nil {
ServeForm(cfg, formReq, rec, req)
return rec
}
rec.WriteHeader(http.StatusNotFound)
return rec
}
return cfg, do
}
func TestSSRCreate_HappyPath(t *testing.T) {
cfg, do := ssrTestSetup(t)
body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"Concrete works"}`
rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil)
if rec.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if loc := rec.Result().Header.Get("Location"); loc != "/Project/ssr/0330C1.yaml" {
t.Errorf("Location=%q want /Project/ssr/0330C1.yaml", loc)
}
// archive/0330C1/ exists.
partyDir := filepath.Join(cfg.Root, "Project", "archive", "0330C1")
if info, err := os.Stat(partyDir); err != nil || !info.IsDir() {
t.Fatalf("party folder not created: err=%v", err)
}
// .zddc auto-own grant.
zf, err := os.ReadFile(filepath.Join(partyDir, ".zddc"))
if err != nil {
t.Fatalf("read auto-own .zddc: %v", err)
}
if !strings.Contains(string(zf), "casey@example.com") {
t.Errorf("auto-own .zddc missing creator email; got %s", string(zf))
}
// ssr.yaml exists and contains the submitted fields but NOT `name`.
yamlBytes, err := os.ReadFile(filepath.Join(partyDir, "ssr.yaml"))
if err != nil {
t.Fatalf("read ssr.yaml: %v", err)
}
yaml := string(yamlBytes)
if !strings.Contains(yaml, "contractNo: PO-001") {
t.Errorf("ssr.yaml missing contractNo; got %s", yaml)
}
if strings.Contains(yaml, "name: 0330C1") {
t.Errorf("ssr.yaml should not carry path-derived `name` field; got %s", yaml)
}
}
func TestSSRCreate_AnonymousRejected(t *testing.T) {
_, do := ssrTestSetup(t)
body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}`
rec := do(http.MethodPost, "/Project/ssr/form.html", "", body, nil)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status=%d want 401; body=%s", rec.Code, rec.Body.String())
}
}
func TestSSRCreate_InvalidName(t *testing.T) {
_, do := ssrTestSetup(t)
cases := []string{
`{"name":".hidden","vendorType":"subcontractor","contractNo":"x","scopeSummary":"x"}`,
`{"name":"with space","vendorType":"subcontractor","contractNo":"x","scopeSummary":"x"}`,
}
for _, body := range cases {
rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil)
if rec.Code != http.StatusUnprocessableEntity && rec.Code != http.StatusBadRequest {
t.Errorf("body=%s status=%d want 422 or 400", body, rec.Code)
}
}
}
func TestSSRCreate_DuplicateName(t *testing.T) {
cfg, do := ssrTestSetup(t)
body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}`
rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil)
if rec.Code != http.StatusCreated {
t.Fatalf("first create failed: status=%d body=%s", rec.Code, rec.Body.String())
}
zddc.InvalidateCache(filepath.Join(cfg.Root, "Project", "archive", "0330C1"))
rec = do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil)
if rec.Code != http.StatusConflict {
t.Errorf("duplicate create: status=%d want 409", rec.Code)
}
}
func TestSSRRename_HappyPath(t *testing.T) {
cfg, do := ssrTestSetup(t)
body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}`
if rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil); rec.Code != http.StatusCreated {
t.Fatalf("setup create failed: %d %s", rec.Code, rec.Body.String())
}
// Drop an MDL row inside the party folder; it should survive the rename.
mdlDir := filepath.Join(cfg.Root, "Project", "archive", "0330C1", "mdl")
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mdlDir, "D-001.yaml"), []byte("id: D-001\n"), 0o644); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(filepath.Join(cfg.Root, "Project", "archive", "0330C1"))
rec := do(http.MethodPost, "/Project/ssr/0330C1.yaml", "casey@example.com", "",
map[string]string{
"X-ZDDC-Op": opSSRRename,
"X-ZDDC-Destination": "/Project/ssr/0330C2.yaml",
})
if rec.Code != http.StatusOK {
t.Fatalf("rename failed: %d body=%s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "archive", "0330C1")); !os.IsNotExist(err) {
t.Error("source party folder still exists after rename")
}
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "archive", "0330C2")); err != nil {
t.Errorf("destination party folder not created: %v", err)
}
// MDL row followed the directory rename.
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "archive", "0330C2", "mdl", "D-001.yaml")); err != nil {
t.Errorf("MDL row did not survive rename: %v", err)
}
}
func TestSSRRename_CrossProjectRejected(t *testing.T) {
cfg, do := ssrTestSetup(t)
body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}`
if rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil); rec.Code != http.StatusCreated {
t.Fatalf("setup create failed: %d", rec.Code)
}
zddc.InvalidateCache(filepath.Join(cfg.Root, "Project"))
rec := do(http.MethodPost, "/Project/ssr/0330C1.yaml", "casey@example.com", "",
map[string]string{
"X-ZDDC-Op": opSSRRename,
"X-ZDDC-Destination": "/OtherProject/ssr/0330C1.yaml",
})
if rec.Code != http.StatusBadRequest {
t.Errorf("cross-project rename: status=%d want 400", rec.Code)
}
}
func TestSSRRename_DestinationExists(t *testing.T) {
cfg, do := ssrTestSetup(t)
bodyA := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}`
bodyB := `{"name":"0330C2","vendorType":"subcontractor","contractNo":"PO-002","scopeSummary":"y"}`
for _, b := range []string{bodyA, bodyB} {
if rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", b, nil); rec.Code != http.StatusCreated {
t.Fatalf("setup create failed: %d %s", rec.Code, rec.Body.String())
}
}
zddc.InvalidateCache(filepath.Join(cfg.Root, "Project"))
rec := do(http.MethodPost, "/Project/ssr/0330C1.yaml", "casey@example.com", "",
map[string]string{
"X-ZDDC-Op": opSSRRename,
"X-ZDDC-Destination": "/Project/ssr/0330C2.yaml",
})
if rec.Code != http.StatusConflict {
t.Errorf("rename to existing: status=%d want 409", rec.Code)
}
}

View file

@ -47,56 +47,78 @@ var embeddedDefaultMdlTable []byte
//go:embed default-mdl.form.yaml //go:embed default-mdl.form.yaml
var embeddedDefaultMdlForm []byte var embeddedDefaultMdlForm []byte
//go:embed default-rsk.table.yaml
var embeddedDefaultRskTable []byte
//go:embed default-rsk.form.yaml
var embeddedDefaultRskForm []byte
//go:embed default-ssr.table.yaml
var embeddedDefaultSsrTable []byte
//go:embed default-ssr.form.yaml
var embeddedDefaultSsrForm []byte
//go:embed default-project-mdl.table.yaml
var embeddedDefaultProjectMdlTable []byte
//go:embed default-project-rsk.table.yaml
var embeddedDefaultProjectRskTable []byte
// DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes. // DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes.
// Used by the static-file handler to serve the default spec at // Used by callers that need the canonical spec without going through
// archive/<party>/mdl.table.yaml when no operator file exists on disk. // the URL-recognition path.
func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable } func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable }
// DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes. // DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes.
func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm } func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm }
// IsDefaultMdlSpec reports whether urlPath is one of the default-MDL // DefaultRskTableYAML returns the embedded default rsk.table.yaml bytes.
// virtual files served when no operator file exists on disk: func DefaultRskTableYAML() []byte { return embeddedDefaultRskTable }
// DefaultRskFormYAML returns the embedded default rsk.form.yaml bytes.
func DefaultRskFormYAML() []byte { return embeddedDefaultRskForm }
// DefaultSsrTableYAML returns the embedded default ssr.table.yaml bytes.
func DefaultSsrTableYAML() []byte { return embeddedDefaultSsrTable }
// DefaultSsrFormYAML returns the embedded default ssr.form.yaml bytes.
func DefaultSsrFormYAML() []byte { return embeddedDefaultSsrForm }
// DefaultProjectMdlTableYAML returns the embedded project-rollup
// mdl.table.yaml bytes.
func DefaultProjectMdlTableYAML() []byte { return embeddedDefaultProjectMdlTable }
// DefaultProjectRskTableYAML returns the embedded project-rollup
// rsk.table.yaml bytes.
func DefaultProjectRskTableYAML() []byte { return embeddedDefaultProjectRskTable }
// IsDefaultSpec reports whether urlPath is one of the embedded
// default-spec virtual files served when no operator file exists on
// disk. Recognized URL shapes:
// //
// <project>/archive/<party>/mdl/table.yaml // <project>/archive/<party>/mdl/{table.yaml, form.yaml}
// <project>/archive/<party>/mdl/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}
// //
// The MDL files live INSIDE the rows-dir along with row YAMLs so the // Returns embedded bytes + true when the fallback should fire; nil +
// whole directory is self-contained — copying mdl/ moves the spec, // false when an operator file exists at that path or the URL is not
// the form, and all rows together. Returns embedded bytes + true when // eligible. Operator files always win.
// the fallback should fire; nil + false when an operator-supplied func IsDefaultSpec(fsRoot, urlPath string) ([]byte, bool) {
// file exists or the path is not eligible.
func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) {
base := strings.ToLower(filepath.Base(urlPath))
var bytes []byte
switch base {
case "table.yaml":
bytes = embeddedDefaultMdlTable
case "form.yaml":
bytes = embeddedDefaultMdlForm
default:
return nil, false
}
if !isAtArchivePartyMdlLevel(fsRoot, urlPath) {
return nil, false
}
// Operator file wins if it exists on disk.
rel := strings.TrimPrefix(filepath.ToSlash(urlPath), "/") rel := strings.TrimPrefix(filepath.ToSlash(urlPath), "/")
abs := filepath.Join(fsRoot, filepath.FromSlash(rel)) abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
if !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) && abs != fsRoot { if !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) && abs != fsRoot {
return nil, false return nil, false
} }
if fileExists(abs) { return IsDefaultSpecAbs(fsRoot, abs)
return nil, false
}
return bytes, true
} }
// IsDefaultMdlSpecAbs is the abs-path-keyed variant of IsDefaultMdlSpec. // IsDefaultSpecAbs is the abs-path-keyed variant of IsDefaultSpec.
// Used by handlers that hold a filesystem path rather than a URL. // Used by handlers that hold a filesystem path rather than a URL.
// Returns the embedded default bytes + true when absPath is the func IsDefaultSpecAbs(fsRoot, absPath string) ([]byte, bool) {
// virtual archive/<party>/{mdl.table.yaml, mdl.form.yaml} fallback.
func IsDefaultMdlSpecAbs(fsRoot, absPath string) ([]byte, bool) {
if !strings.HasPrefix(absPath, fsRoot+string(filepath.Separator)) && absPath != fsRoot { if !strings.HasPrefix(absPath, fsRoot+string(filepath.Separator)) && absPath != fsRoot {
return nil, false return nil, false
} }
@ -104,8 +126,87 @@ func IsDefaultMdlSpecAbs(fsRoot, absPath string) ([]byte, bool) {
if err != nil { if err != nil {
return nil, false return nil, false
} }
urlPath := "/" + filepath.ToSlash(rel) rel = filepath.ToSlash(rel)
return IsDefaultMdlSpec(fsRoot, urlPath) if rel == "" || rel == "." || strings.HasPrefix(rel, "../") {
return nil, false
}
bytes := classifyDefaultSpec(rel)
if bytes == nil {
return nil, false
}
// Operator file wins if it exists on disk.
if fileExists(absPath) {
return nil, false
}
return bytes, true
}
// classifyDefaultSpec maps a slash-form path (relative to fsRoot) to
// the matching embedded default-spec bytes, or nil if the path does
// not name one of the recognized virtual fallback files.
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 "mdl":
switch file {
case "table.yaml":
return embeddedDefaultMdlTable
case "form.yaml":
return embeddedDefaultMdlForm
}
case "rsk":
switch file {
case "table.yaml":
return embeddedDefaultRskTable
case "form.yaml":
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])
file := strings.ToLower(parts[2])
switch slot {
case "ssr":
switch file {
case "table.yaml":
return embeddedDefaultSsrTable
case "form.yaml":
return embeddedDefaultSsrForm
}
case "mdl":
switch file {
case "table.yaml":
return embeddedDefaultProjectMdlTable
case "form.yaml":
return embeddedDefaultMdlForm
}
case "rsk":
switch file {
case "table.yaml":
return embeddedDefaultProjectRskTable
case "form.yaml":
return embeddedDefaultRskForm
}
}
}
return nil
} }
// isAtArchivePartyLevel reports whether urlPath refers to a file // isAtArchivePartyLevel reports whether urlPath refers to a file
@ -114,33 +215,19 @@ func IsDefaultMdlSpecAbs(fsRoot, absPath string) ([]byte, bool) {
func isAtArchivePartyLevel(fsRoot, urlPath string) bool { func isAtArchivePartyLevel(fsRoot, urlPath string) bool {
rel := strings.Trim(filepath.ToSlash(urlPath), "/") rel := strings.Trim(filepath.ToSlash(urlPath), "/")
parts := strings.Split(rel, "/") parts := strings.Split(rel, "/")
// <project>/archive/<party>/<file> = 4 segments
if len(parts) != 4 { if len(parts) != 4 {
return false return false
} }
return strings.EqualFold(parts[1], "archive") return strings.EqualFold(parts[1], "archive")
} }
// isAtArchivePartyMdlLevel reports whether urlPath refers to a file
// directly under <project>/archive/<party>/mdl/ (depth-4 directory).
// Used by the default-MDL fallback after the spec/form moved INSIDE
// the rows-dir for self-containment.
func isAtArchivePartyMdlLevel(fsRoot, urlPath string) bool {
rel := strings.Trim(filepath.ToSlash(urlPath), "/")
parts := strings.Split(rel, "/")
// <project>/archive/<party>/mdl/<file> = 5 segments
if len(parts) != 5 {
return false
}
return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "mdl")
}
// TableRequest describes a recognized table-system request. // TableRequest describes a recognized table-system request.
type TableRequest struct { type TableRequest struct {
// Name is the table's URL stem (the key declared in .zddc tables). // Name is the table's URL stem (the key declared in .zddc tables).
Name string Name string
// SpecPath is the absolute filesystem path to the *.table.yaml. // SpecPath is the absolute filesystem path to the *.table.yaml.
// Validated to exist at recognition time. // May reference a virtual path when the spec is served from
// embedded defaults.
SpecPath string SpecPath string
// Dir is the absolute path to the request directory (where the // Dir is the absolute path to the request directory (where the
// .zddc declared the table). // .zddc declared the table).
@ -149,15 +236,14 @@ type TableRequest struct {
// tableRowsRedirect reports the canonical /<dir>/table.html URL to // tableRowsRedirect reports the canonical /<dir>/table.html URL to
// redirect to when (urlPath) names a directory that contains a // redirect to when (urlPath) names a directory that contains a
// table.yaml (or matches the default-MDL fallback). Returns "" when // table.yaml (or matches one of the default-spec fallbacks). Returns
// no redirect should fire. // "" when no redirect should fire.
// //
// Recognition reuses RecognizeTableRequest by synthesizing the // Recognition reuses RecognizeTableRequest by synthesizing the
// equivalent <urlPath>table.html and asking the recognizer whether // equivalent <urlPath>table.html and asking the recognizer whether
// it's a real (or default-MDL) table. Single source of truth for // it's a real (or default-spec) table. Single source of truth for
// validation. // validation.
func tableRowsRedirect(fsRoot, urlPath string) string { func tableRowsRedirect(fsRoot, urlPath string) string {
// urlPath is the directory request — e.g. "/proj/archive/Acme/mdl/".
if urlPath == "" || urlPath == "/" { if urlPath == "" || urlPath == "/" {
return "" return ""
} }
@ -169,11 +255,11 @@ func tableRowsRedirect(fsRoot, urlPath string) string {
if tr == nil { if tr == nil {
return "" return ""
} }
// Default-MDL case (no on-disk table.yaml): follow the slash/no- // Default-spec case (no on-disk table.yaml): follow the slash/no-
// slash convention — slash form serves browse, no-slash serves // slash convention — slash form serves browse, no-slash serves
// tables (handled by the dispatcher). Redirecting here would // tables (handled by the dispatcher). Redirecting here would
// override the convention and force the user into the table view // override the convention and force the user into the table view
// from any /<party>/mdl/ click. // from any /<party>/{mdl,rsk}/ click.
if !fileExists(tr.SpecPath) { if !fileExists(tr.SpecPath) {
return "" return ""
} }
@ -183,15 +269,13 @@ func tableRowsRedirect(fsRoot, urlPath string) string {
// RecognizeTableRequest classifies r as a table-system request, or // RecognizeTableRequest classifies r as a table-system request, or
// returns nil if it falls through to other handlers. Discovery is // returns nil if it falls through to other handlers. Discovery is
// presence-based and self-contained: a /<dir>/table.html URL fires // presence-based and self-contained: a /<dir>/table.html URL fires
// when <dir>/table.yaml exists on disk, or when the default-MDL // when <dir>/table.yaml exists on disk, or when one of the default-
// fallback at archive/<party>/mdl/ applies. // spec fallbacks applies (per-party mdl/rsk under archive/<party>/,
// or project-level ssr/mdl/rsk virtual aggregations).
// //
// The spec, the row-edit form, and all rows live together in <dir>. // The table's "name" is the directory's basename for on-disk and
// Copying <dir> elsewhere copies everything needed to re-host the // per-party-virtual tables (e.g. "mdl"); for project-level virtual
// table — that's the whole point of the in-dir layout. // tables it's the slot name ("ssr", "mdl", "rsk").
//
// The table's "name" is the directory's basename (so the URL
// /<parent>/mdl/table.html names the "mdl" table, with rows in mdl/).
// //
// Methods other than GET return nil — the table is read-only at the // Methods other than GET return nil — the table is read-only at the
// URL level. Writes go through the file API directly. // URL level. Writes go through the file API directly.
@ -203,12 +287,10 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
return nil return nil
} }
// Strip /table.html — what remains is the rows-dir.
rel := strings.TrimSuffix(strings.TrimPrefix(urlPath, "/"), "/table.html") rel := strings.TrimSuffix(strings.TrimPrefix(urlPath, "/"), "/table.html")
rel = strings.TrimSuffix(rel, "table.html") // handles "/table.html" at root → "" rel = strings.TrimSuffix(rel, "table.html")
rel = strings.Trim(rel, "/") rel = strings.Trim(rel, "/")
if rel == "" { if rel == "" {
// /table.html at root has no rows-dir to name.
return nil return nil
} }
dirAbs := filepath.Join(fsRoot, filepath.FromSlash(rel)) dirAbs := filepath.Join(fsRoot, filepath.FromSlash(rel))
@ -224,34 +306,49 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs} return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs}
} }
// Default-MDL virtual-spec fallback at archive/<party>/mdl/. The // Default-spec fallbacks — the rows-dir itself may not exist on
// spec bytes come from IsDefaultMdlSpec via the static-file // disk yet (fully virtual). The static-file dispatcher serves the
// dispatcher when no on-disk file exists at that path; the rows-dir // embedded spec bytes from IsDefaultSpecAbs when the client
// itself doesn't need to exist either (fully virtual archive). // fetches <dir>/table.yaml client-side.
if isAtArchivePartyMdlDir(fsRoot, dirAbs) { if slot, ok := classifyVirtualTableDir(fsRoot, dirAbs); ok {
return &TableRequest{Name: "mdl", SpecPath: specAbs, Dir: dirAbs} return &TableRequest{Name: slot, SpecPath: specAbs, Dir: dirAbs}
} }
return nil return nil
} }
// isAtArchivePartyMdlDir reports whether dirAbs is exactly // classifyVirtualTableDir reports whether dirAbs is one of the
// <fsRoot>/<project>/archive/<party>/mdl. Used by the default-MDL // virtual-spec table dirs and returns its slot name ("mdl", "rsk",
// fallback to recognize the virtual rows-dir whether or not it // or "ssr"). Recognizes both per-party slots
// exists on disk. // (<project>/archive/<party>/{mdl,rsk}) and project-level slots
func isAtArchivePartyMdlDir(fsRoot, dirAbs string) bool { // (<project>/{ssr,mdl,rsk}).
func classifyVirtualTableDir(fsRoot, dirAbs string) (string, bool) {
rel, err := filepath.Rel(fsRoot, dirAbs) rel, err := filepath.Rel(fsRoot, dirAbs)
if err != nil { if err != nil {
return false return "", false
} }
rel = filepath.ToSlash(rel) rel = filepath.ToSlash(rel)
if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." { if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." {
return false return "", false
} }
parts := strings.Split(rel, "/") parts := strings.Split(rel, "/")
if len(parts) != 4 { switch len(parts) {
return false case 2:
// <project>/<slot>
slot := strings.ToLower(parts[1])
if slot == "ssr" || slot == "mdl" || slot == "rsk" {
return slot, true
}
case 4:
// <project>/archive/<party>/<slot>
if !strings.EqualFold(parts[1], "archive") {
return "", false
}
slot := strings.ToLower(parts[3])
if slot == "mdl" || slot == "rsk" {
return slot, true
}
} }
return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "mdl") return "", false
} }
// isNotExistError reports whether err indicates a missing file. Local // isNotExistError reports whether err indicates a missing file. Local

View file

@ -147,8 +147,11 @@ func TestRecognizeTableRequest(t *testing.T) {
{"DELETE", "/Working/MDL/table.html", true, "", ""}, {"DELETE", "/Working/MDL/table.html", true, "", ""},
// No table.yaml in this dir → not a table request. // No table.yaml in this dir → not a table request.
{"GET", "/Working/Other/table.html", true, "", ""}, {"GET", "/Working/Other/table.html", true, "", ""},
// No table.yaml anywhere → not a table request. // /<project>/mdl/ now resolves as the project-level virtual MDL
{"GET", "/Other/MDL/table.html", true, "", ""}, // rollup (independent of any on-disk file). Recognized as the
// virtual table named "mdl"; the spec bytes are served from
// embedded defaults via IsDefaultSpec on the client fetch.
{"GET", "/Other/mdl/table.html", false, "Other/mdl/table.yaml", "mdl"},
// Random .html → falls through. // Random .html → falls through.
{"GET", "/index.html", true, "", ""}, {"GET", "/index.html", true, "", ""},
// /form.html in the same dir is form territory, not a table. // /form.html in the same dir is form territory, not a table.
@ -295,14 +298,14 @@ func TestRecognizeTableRequest_DefaultOnlyAtPartyLevel(t *testing.T) {
} }
} }
func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) { func TestIsDefaultSpec_MDL_ServesEmbeddedYAML(t *testing.T) {
root := t.TempDir() root := t.TempDir()
// archive/Acme/ exists but no mdl/table.yaml on disk. // archive/Acme/ exists but no mdl/table.yaml on disk.
if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
bts, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/table.yaml") bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/mdl/table.yaml")
if !ok { if !ok {
t.Fatalf("expected fallback to fire") t.Fatalf("expected fallback to fire")
} }
@ -310,7 +313,7 @@ func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))]) t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
} }
bts, ok = IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/form.yaml") bts, ok = IsDefaultSpec(root, "/Project/archive/Acme/mdl/form.yaml")
if !ok { if !ok {
t.Fatalf("expected form fallback to fire") t.Fatalf("expected form fallback to fire")
} }
@ -319,7 +322,7 @@ func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
} }
} }
func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) { func TestIsDefaultSpec_MDL_OperatorFileWins(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mdlDir := filepath.Join(root, "Project", "archive", "Acme", "mdl") mdlDir := filepath.Join(root, "Project", "archive", "Acme", "mdl")
if err := os.MkdirAll(mdlDir, 0o755); err != nil { if err := os.MkdirAll(mdlDir, 0o755); err != nil {
@ -328,12 +331,12 @@ func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) {
if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/table.yaml"); ok { if _, ok := IsDefaultSpec(root, "/Project/archive/Acme/mdl/table.yaml"); ok {
t.Errorf("operator file should win over embedded fallback") t.Errorf("operator file should win over embedded fallback")
} }
} }
func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) { func TestIsDefaultSpec_MDL_OnlyAtArchivePartyLevel(t *testing.T) {
root := t.TempDir() root := t.TempDir()
cases := []string{ cases := []string{
"/Project/working/mdl/table.yaml", "/Project/working/mdl/table.yaml",
@ -341,9 +344,100 @@ func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) {
"/Project/archive/Acme/sub/mdl/table.yaml", "/Project/archive/Acme/sub/mdl/table.yaml",
} }
for _, p := range cases { for _, p := range cases {
if _, ok := IsDefaultMdlSpec(root, p); ok { if _, ok := IsDefaultSpec(root, p); ok {
t.Errorf("path %q should NOT trigger default fallback", p) t.Errorf("path %q should NOT trigger default fallback", p)
} }
} }
} }
// --- RSK + SSR + project-rollup default-spec recognition --------------------
func TestRecognizeTableRequest_DefaultRskAtArchiveParty(t *testing.T) {
_, do := archivePartyTestSetup(t, "")
rec := do(http.MethodGet, "/Project/archive/Acme/rsk/table.html", "alice@example.com")
if rec.Code != http.StatusOK {
t.Fatalf("default rsk recognition: want 200, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestRecognizeTableRequest_ProjectVirtualTables(t *testing.T) {
_, do := archivePartyTestSetup(t, "")
for _, slot := range []string{"ssr", "mdl", "rsk"} {
rec := do(http.MethodGet, "/Project/"+slot+"/table.html", "alice@example.com")
if rec.Code != http.StatusOK {
t.Errorf("project-level virtual table %q: want 200, got %d", slot, rec.Code)
}
}
}
func TestIsDefaultSpec_RSK_ServesEmbeddedYAML(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil {
t.Fatal(err)
}
bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/rsk/table.yaml")
if !ok {
t.Fatalf("expected RSK table fallback to fire")
}
if !strings.Contains(string(bts), "Risk Register") {
t.Errorf("default RSK table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
}
bts, ok = IsDefaultSpec(root, "/Project/archive/Acme/rsk/form.yaml")
if !ok {
t.Fatalf("expected RSK form fallback to fire")
}
if !strings.Contains(string(bts), "Risk") {
t.Error("default RSK form spec missing expected title")
}
}
func TestIsDefaultSpec_SSR_PerParty(t *testing.T) {
root := t.TempDir()
// archive/<party>/ssr.form.yaml — per-party SSR schema.
bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/ssr.form.yaml")
if !ok {
t.Fatalf("expected per-party SSR schema fallback to fire")
}
if !strings.Contains(string(bts), "Supplier") {
t.Errorf("per-party SSR schema missing expected header")
}
}
func TestIsDefaultSpec_ProjectLevel(t *testing.T) {
root := t.TempDir()
cases := []struct {
url string
contains string
}{
{"/Project/ssr/table.yaml", "Supplier"},
{"/Project/ssr/form.yaml", "Supplier"},
{"/Project/mdl/table.yaml", "Project Deliverables"},
{"/Project/mdl/form.yaml", "Deliverable"},
{"/Project/rsk/table.yaml", "Project Risk Register"},
{"/Project/rsk/form.yaml", "Risk"},
}
for _, tc := range cases {
bts, ok := IsDefaultSpec(root, tc.url)
if !ok {
t.Errorf("%s: expected fallback to fire", tc.url)
continue
}
if !strings.Contains(string(bts), tc.contains) {
t.Errorf("%s: body missing %q; got %q…", tc.url, tc.contains, string(bts)[:min(120, len(bts))])
}
}
}
func TestIsDefaultSpec_ProjectLevel_OperatorOverrides(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "Project", "ssr"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Project", "ssr", "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, ok := IsDefaultSpec(root, "/Project/ssr/table.yaml"); ok {
t.Errorf("operator file should win at /Project/ssr/table.yaml")
}
}

View file

@ -1511,7 +1511,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-18 22:38:21 · 85e6eb1-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 02:25:26 · da4754b-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -3548,6 +3548,11 @@ body.is-elevated::after {
description: spec.description, description: spec.description,
columns: spec.columns, columns: spec.columns,
defaults: spec.defaults, defaults: spec.defaults,
// addable defaults to true; tables can opt out with
// `addable: false` (used by project-rollup MDL/RSK where the
// party affiliation of a new row is ambiguous — add at the
// per-party path instead).
addable: spec.addable !== false,
rowSchema: rowSchema, rowSchema: rowSchema,
rows: rows rows: rows
}; };
@ -6231,7 +6236,11 @@ body.is-elevated::after {
if (addRowBtn) { if (addRowBtn) {
const onHttp = location.protocol === 'http:' || location.protocol === 'https:'; const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0; const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
if (onHttp && hasCols) { // ctx.addable === false suppresses the affordance entirely.
// Used by project-rollup tables where the row's party
// affiliation is ambiguous (add at the per-party path).
const allowAdd = ctx.addable !== false;
if (onHttp && hasCols && allowAdd) {
addRowBtn.hidden = false; addRowBtn.hidden = false;
addRowBtn.removeAttribute('href'); addRowBtn.removeAttribute('href');
addRowBtn.setAttribute('role', 'button'); addRowBtn.setAttribute('role', 'button');

View file

@ -0,0 +1,101 @@
// Package handler — virtualviewhandler.go: GET dispatch for SSR row +
// MDL/RSK rollup row URLs.
//
// 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:
//
// - SSR rows get `name: <party>` so the table renderer has a column
// to sort on and the form edit pre-fills the party name.
// - MDL / RSK rollup rows get `party: <party>` so the rollup table
// can show which package each row came from.
//
// 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.
package handler
import (
"net/http"
"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)
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 {
http.NotFound(w, r)
return
}
raw = nil
}
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)
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
}
out, err := yaml.Marshal(data)
if err != nil {
http.Error(w, "marshal virtual 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("Content-Length", strconv.Itoa(len(out)))
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
_, _ = w.Write(out)
}

View file

@ -153,6 +153,13 @@ paths:
# tables tool serves it from the embedded default # tables tool serves it from the embedded default
# spec even when the on-disk folder doesn't exist. # spec even when the on-disk folder doesn't exist.
virtual: true virtual: true
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
incoming: incoming:
# incoming/ is the COUNTERPARTY's drop zone. The flow: # incoming/ is the COUNTERPARTY's drop zone. The flow:
# 1. the other party's document controller uploads # 1. the other party's document controller uploads
@ -254,3 +261,29 @@ paths:
auto_own: true auto_own: true
drop_target: true drop_target: true
admins: [document_controller] admins: [document_controller]
# Project-level aggregation tables. All three are virtual: the
# folder doesn't exist on disk; the server synthesizes listings
# by walking archive/*/ at request time. ACL on each synthetic
# row is evaluated against the canonical archive/<party>/ path,
# so party owners can edit their own rows and non-owners see
# them read-only.
ssr:
default_tool: tables
available_tools: [tables]
# SSR aggregates one row per party folder; the row's backing
# file is archive/<party>/ssr.yaml. + Add row in this view
# creates a new party folder.
virtual: true
mdl:
default_tool: tables
available_tools: [tables]
# Project-rollup of every archive/<party>/mdl/ row. Read +
# edit; + Add row is disabled because party affiliation is
# ambiguous here (add at the per-party path instead).
virtual: true
rsk:
default_tool: tables
available_tools: [tables]
# Project-rollup of every archive/<party>/rsk/ row. Same
# semantics as the mdl rollup.
virtual: true

View file

@ -184,13 +184,16 @@ func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) {
} }
// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project // TestChildrenDeclaredAt_FromEmbeddedConvention — at a project
// root, the four canonical children should be enumerated. // root, the canonical children should be enumerated: the four
// physical folders (archive, working, staging, reviewing) plus the
// three project-level virtual aggregator slots (ssr, mdl, rsk).
func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) { func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() root := t.TempDir()
got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X")) got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X"))
want := map[string]bool{ want := map[string]bool{
"archive": true, "working": true, "staging": true, "reviewing": true, "archive": true, "working": true, "staging": true, "reviewing": true,
"ssr": true, "mdl": true, "rsk": true,
} }
if len(got) != len(want) { if len(got) != len(want) {
t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want) t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want)