refactor: nest lifecycle slots per-party + add virtual top-level aggregators
May 2026 reshape. archive/ is now the only physical project-root
directory; working/, staging/, reviewing/ move from the project root
into each archive/<party>/ folder. Six top-level URLs become virtual
aggregators served via the cascade rather than disk:
ssr/mdl/rsk tables rollups across parties with a
synthesised $party source-party column
working/staging/ browse folder-nav listings of parties with
reviewing non-empty content in the slot; per-party
URLs 302-redirect to archive/<party>/<slot>/
Mkdir at the project root is restricted to `archive` and `_`/`.`-
prefixed system names — virtual aggregator names and ad-hoc folders
return 409.
Plan Review hardcodes the scaffold convention (archive/<party>/
{reviewing,staging}/<tracking>/); the pre-reshape
on_plan_review.{reviewing_root,staging_root} cascade keys are dropped.
document_controller is now subtree-admin of every archive/<party>/
(not of project-root working/staging/ as before), so per-party
lifecycle slots inherit admin authority through the cascade.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
703449adc5
commit
59b5550872
34 changed files with 1239 additions and 727 deletions
|
|
@ -287,7 +287,7 @@ The build enforces lockstep mechanically (one command bumps all 8). The rules be
|
|||
No install script. Two paths:
|
||||
|
||||
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
|
||||
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` everywhere, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor), `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
|
||||
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive/<party>/incoming/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. **Project shape (May 2026 reshape):** `archive/` is the only physical project-root directory. Six top-level URLs are virtual aggregators: `ssr/mdl/rsk` (tables row-rollups across parties, with a synthesised `$party` source-party column the tables tool renders read-only and strips before write) and `working/staging/reviewing` (browse folder-nav listings of parties with non-empty content; per-party URLs 302-redirect to `archive/<party>/<slot>/`). Mkdir directly at the project root is restricted to `archive` and `_`/`.`-prefixed system names — virtual aggregator names and ad-hoc folders return 409. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
|
||||
|
||||
To override at any level, either:
|
||||
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
|
||||
|
|
@ -491,7 +491,7 @@ roles:
|
|||
- '*@acme.com'
|
||||
```
|
||||
|
||||
The embedded cascade already grants `project_team: r` project-wide and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, subtree-admin on `working/`/`staging/`/`reviewing/`). Populating role members lights all of that up.
|
||||
The embedded cascade already grants `project_team: r` project-wide and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, subtree-admin of every `archive/<party>/` so they own each party's lifecycle slots — `working/`, `staging/`, `reviewing/`, `incoming/`). Populating role members lights all of that up.
|
||||
|
||||
**Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`):
|
||||
|
||||
|
|
@ -605,7 +605,7 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
|
|||
|
||||
**URLs are case-insensitive.** The dispatcher canonicalizes `r.URL.Path` against on-disk casing before any handler runs (`zddc/internal/fs/resolve.go ResolveCanonical`). Per segment: lowercase variant wins if it exists on disk; otherwise exact-case wins; otherwise readdir+CI scan with the lowercase variant winning the tiebreak when multiple case variants are siblings on disk. Walk stops at the first segment that doesn't exist so virtual prefixes (`.archive`, `.profile`, `.tokens`, `.api`, `.auth`) and 404 paths flow through with their tail preserved verbatim.
|
||||
|
||||
**File and folder names preserve case on disk.** The canonicalization is purely a URL→filesystem-name mapping; nothing renames anything. Lowercase is the *project-wide canonical* convention, and auto-created folders in `internal/zddc/ensure.go` (`working/`, `staging/`, `archive/<party>/incoming/`) and the server's own state dirs (`_app/`, `.zddc.d/tokens/`, `.zddc.d/outbox/`, `.zddc.d/logs/`) are all lowercase by string literal. Operators can drop a `Mixed-Case-Folder/` and it stays mixed-case.
|
||||
**File and folder names preserve case on disk.** The canonicalization is purely a URL→filesystem-name mapping; nothing renames anything. Lowercase is the *project-wide canonical* convention, and auto-created folders in `internal/zddc/ensure.go` (the per-party `archive/<party>/{working,staging,reviewing,incoming}/`) and the server's own state dirs (`_app/`, `.zddc.d/tokens/`, `.zddc.d/outbox/`, `.zddc.d/logs/`) are all lowercase by string literal. Operators can drop a `Mixed-Case-Folder/` and it stays mixed-case.
|
||||
|
||||
**Audit log captures the as-typed path.** `AccessLogMiddleware` snapshots `r.URL.Path` before dispatch rewrites it; the audit record's `path` field is what the client sent. When canonicalization changed it, a `resolved_path` field is added.
|
||||
|
||||
|
|
|
|||
|
|
@ -679,7 +679,16 @@ The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypa
|
|||
|
||||
#### Canonical folders, URL routing & the `.zddc` cascade
|
||||
|
||||
There are **no hardcoded folder names** — the canonical project structure (`archive/`, `working/`, `staging/`, `reviewing/`; `archive/<party>/{mdl,incoming,received,issued}/`) is described by a baked-in baseline `.zddc` (`zddc/internal/zddc/defaults.zddc.yaml`), loaded as the bottom layer of the cascade. `zddc-server show-defaults` dumps it; operators override at the on-disk root (or any deeper level) by mirroring the structure and changing what they need (on-disk wins per field). Setting file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer entirely — **including the structural convention (WORM zones, per-user fences, virtual folders)**, not just the default ACLs, so it's a blunt instrument.
|
||||
There are **no hardcoded folder names** — the canonical project structure is described by a baked-in baseline `.zddc` (`zddc/internal/zddc/defaults.zddc.yaml`), loaded as the bottom layer of the cascade. `zddc-server show-defaults` dumps it; operators override at the on-disk root (or any deeper level) by mirroring the structure and changing what they need (on-disk wins per field). Setting file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer entirely — **including the structural convention (WORM zones, per-user fences, virtual folders)**, not just the default ACLs, so it's a blunt instrument.
|
||||
|
||||
**Project shape (after the May 2026 reshape).** `archive/` is the only physical project-root directory. Everything party-scoped lives uniformly under `archive/<party>/{ssr.yaml, mdl/, rsk/, received/, issued/, incoming/, working/<email>/, staging/<batch>/, reviewing/<tracking>/}`. Six sibling top-level URLs are **virtual aggregators**, never on disk:
|
||||
|
||||
- **Row rollups** (tables tool, `default_tool: tables`) — `<project>/ssr`, `<project>/mdl`, `<project>/rsk`. Synthesise one row per party (SSR) or per row file across parties (MDL/RSK), with the source party injected as a synthesised `$party` column. The `$` sigil marks the column system-managed: the tables tool renders it read-only and strips it before submitting a write. Form-mode "+ Add row" on a rollup view prompts for `party` (the routing key, stored in the form schema as a real input field; stripped on write because the folder name *is* the identity).
|
||||
- **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `<project>/working`, `<project>/staging`, `<project>/reviewing`. List the parties whose `archive/<party>/<slot>/` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `<project>/<slot>/<party>[/<rest>]` 302-redirect to the canonical `<project>/archive/<party>/<slot>[/<rest>]`. No writes through the virtual URL space; sharing/bookmarks land on the canonical path after the redirect.
|
||||
|
||||
Mkdir at the project root is restricted: only `archive` and `_`/`.`-prefixed system names are accepted (`handler/fileapi.go: rejectProjectRootMkdir`). Any other name — including the six virtual aggregator names, which would shadow the virtual surface — returns 409 Conflict. This is the only structural mkdir guard; deeper paths are governed by `auto_own:` + `worm:` + ACL.
|
||||
|
||||
Plan Review (`X-ZDDC-Op: plan-review`) hardcodes the scaffold convention: workflow folders always land at `<project>/archive/<party>/{reviewing,staging}/<tracking>/`, derived from the originating submittal's path. The pre-reshape `on_plan_review.reviewing_root` / `staging_root` cascade keys were dropped — one convention, no per-project override surface. The `X-ZDDC-On-Plan-Review` response header (set by `directory.go`) lights up on every `/<project>/archive/<party>/received/<tracking>/` URL via the structural `zddc.IsPlanReviewURL` test, so the browse client knows when to show the menu item without re-implementing the cascade.
|
||||
|
||||
The schema keys that drive built-in behavior:
|
||||
|
||||
|
|
@ -696,7 +705,7 @@ The schema keys that drive built-in behavior:
|
|||
| `roles` | `{ name → { members:[], reset:bool } }` | members union across cascade; `reset:true` stops the walk |
|
||||
| `paths` | recursive map of child-path → `.zddc` overlay; the engine of the whole convention | replaces (the walker threads ancestor `paths:` to the right level) |
|
||||
|
||||
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor plugin), `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
|
||||
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` rollups). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
|
||||
|
||||
**Zip-backed directories.** A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON listing of the zip's members (or the browse SPA for an HTML request) and `GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member — so a client navigating a zipped transmittal folder never downloads the whole archive. `GET …/Foo.zip` (no trailing slash) is unchanged: the raw `.zip` download. Read-only: `PUT`/`DELETE`/`POST` to a path inside a `.zip` is rejected (405). ACL is the chain of the directory *containing* the zip — a zip carries no `.zddc` of its own, the same model as the `.archive` virtual surface. Implemented by `internal/zipfs` + `handler.ServeZip`, routed via `splitZipPath` in the dispatcher (before the file-API branch). Offline tools (archive's scanner, browse's tree) get the same capability client-side via `shared/zip-source.js` — a `ZipDirectoryHandle`/`ZipFileHandle` pair over JSZip that mimics the File-System-Access surface. The archive tool treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder; the browse tool expands *any* `.zip`.
|
||||
|
||||
|
|
@ -704,7 +713,7 @@ The schema keys that drive built-in behavior:
|
|||
|
||||
**WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Admins (root / subtree) bypass entirely — the escape hatch for mis-filed documents. `defaults.zddc.yaml` puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change.
|
||||
|
||||
**Standard roles.** `defaults.zddc.yaml` references two roles (both shipped empty — a fresh deployment grants nothing until an operator populates them): `document_controller` (read/write across a project, `rwc` at `archive/`, subtree-admin of `working/` and `staging/`, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow) and `project_team` (read-only across the project; their own `working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule).
|
||||
**Standard roles.** `defaults.zddc.yaml` references two roles (both shipped empty — a fresh deployment grants nothing until an operator populates them): `document_controller` (read/write across a project, `rwc` at `archive/`, subtree-admin of every `archive/<party>/` and its in-flight slots, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow) and `project_team` (read-only across the project; their own `archive/<party>/working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule).
|
||||
|
||||
### File API (authenticated CRUD)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ This is a **monorepo of independent tools**, not one application:
|
|||
- `archive/`, `transmittal/`, `classifier/`, `landing/`, `form/`, `tables/`, `browse/` — seven self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). `form/` is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); `tables/` is its read/aggregate counterpart, rendering a directory of YAML rows as a sortable table; `browse/` is the file-tree navigator and **also hosts the in-place markdown editor** (`browse/js/preview-markdown.js` — Toast UI Editor + YAML front-matter pane + on-demand server-side MD→DOCX/HTML/PDF download buttons). A dedicated `mdedit/` tool used to live alongside these but has been retired. See AGENTS.md "Form-data system" / "Tables system" and ARCHITECTURE.md "Form Renderer".
|
||||
- `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Two deployment shapes from the same binary: (1) **master** — owns a file tree under `ZDDC_ROOT`, applies `.zddc` ACL cascades, serves files / app HTML / archive listings. Two auth paths on master: `Authorization: Bearer <token>` validated against self-issued tokens at `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` for CLI/scripted callers, or `X-Auth-Request-Email` injected by an upstream proxy for browser sessions. Self-service token UI at `/.tokens` + JSON API at `/.api/tokens`. (2) **client** — when `--upstream <url>` is set, the binary becomes a downstream proxy/cache/mirror (`zddc/internal/cache/`); master-side machinery is bypassed and `--root` becomes the cache directory. Three sub-modes via `--mode proxy|cache|mirror` (mirror is phase 3). Cache layout is a normal ZDDC root, so the cache dir can be served as a plain master if you unset `--upstream`. Marker file `.zddc-upstream` records provenance. `--no-auth` skips ACL enforcement entirely on this instance (distinct from `--insecure` which only relaxes the no-root-`.zddc` startup check); `--skip-tls-verify` is a separate flag for self-signed upstream certs. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time.
|
||||
- `shared/` — CSS (`base.css`, `fonts.css` + base64-inlined woff2 under `fonts/`, `nav.css`, `logo.css`, `toast.css`) plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `zddc-source.js`, `zip-source.js`, `theme.js`, `toast.js`, `nav.js`, `logo.js`, `help.js`, `preview-lib.js`) and vendored libs (`vendor/`: jszip, xlsx, utif, docx-preview, toastui-editor) — each tool's `build.sh` concatenates the subset it needs. Also `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers). See AGENTS.md "Shared modules" for the full inventory.
|
||||
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all seven HTML tools baked in via `//go:embed` (compile-time default). **Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded folder names** — a baked-in `defaults.zddc.yaml` (dump it: `zddc-server show-defaults`) declares, via a recursive `paths:` tree, per-folder `default_tool` (served at `<dir>` — `archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor), `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at root), `dir_tool` (served at `<dir>/`; defaults to `browse`), `available_tools`, plus the canonical-folder behaviour keys (`auto_own`, `worm:`, `virtual`, `drop_target`); operators override at any level. A `.zip` file is also a navigable directory (`GET …/Foo.zip/` → member listing; `…/Foo.zip/m.pdf` → that member); `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* via a `.zddc apps:` entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`; or drop a real `.html` at any path. See AGENTS.md "URL handling"/"Install model" and ARCHITECTURE.md "Canonical folders, URL routing & the `.zddc` cascade".
|
||||
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all seven HTML tools baked in via `//go:embed` (compile-time default). **Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded folder names** — a baked-in `defaults.zddc.yaml` (dump it: `zddc-server show-defaults`) declares, via a recursive `paths:` tree, per-folder `default_tool` (served at `<dir>` — `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive/<party>/incoming/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at root), `dir_tool` (served at `<dir>/`; defaults to `browse`), `available_tools`, plus the canonical-folder behaviour keys (`auto_own`, `worm:`, `virtual`, `drop_target`); operators override at any level. **Project shape (May 2026 reshape):** `archive/` is the only physical project-root directory. Six top-level URLs are virtual aggregators: `ssr/mdl/rsk` (tables rollups across parties with a synthesized `$party` source-party column) and `working/staging/reviewing` (browse folder-nav listings of parties with non-empty content; per-party clicks 302-redirect to `archive/<party>/<slot>/`). Mkdir at project root is restricted to `archive` + `_`/`.`-prefixed system names; the six virtual names are rejected with 409. A `.zip` file is also a navigable directory (`GET …/Foo.zip/` → member listing; `…/Foo.zip/m.pdf` → that member); `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* via a `.zddc apps:` entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`; or drop a real `.html` at any path. See AGENTS.md "URL handling"/"Install model" and ARCHITECTURE.md "Canonical folders, URL routing & the `.zddc` cascade".
|
||||
- `helm/` — example Helm charts for zddc-server. Three flavors: `zddc-server-prod/` (production master), `zddc-server-dev/` (development master with OverlayFS isolation), `zddc-server-cache/` (downstream client running in proxy/cache/mirror mode against an upstream master, with bearer token from a Kubernetes Secret). All compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo — the cache chart references a separately-created Secret for the bearer token.
|
||||
- `tests/` — Playwright specs (Chromium only, requires File System Access API). `tests/schema.spec.js` validates `transmittal.schema.json` against canonical fixtures via `ajv` (only dev dep besides Playwright)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
// stage.js — Stage and Unstage workflow modals.
|
||||
//
|
||||
// Stage: move a file from working/<…>/ into a transmittal folder under
|
||||
// staging/<…>/. Modal lists existing transmittal folders in staging/
|
||||
// plus a "New transmittal folder…" option that prompts for a ZDDC-
|
||||
// conforming name and mkdirs it before the move.
|
||||
// After the layout reshape, working/ and staging/ live INSIDE each
|
||||
// party folder: archive/<party>/working/<email>/<file> and
|
||||
// archive/<party>/staging/<batch>/<file>. Stage and Unstage are now
|
||||
// per-party — the destination batch is always inside the SAME
|
||||
// party's staging slot. The party context is read from the source
|
||||
// file's path.
|
||||
//
|
||||
// Unstage: move a file from staging/<transmittal>/ back to the user's
|
||||
// working/<email>/ home (overridable).
|
||||
// Stage: move a file from archive/<party>/working/<…> into a
|
||||
// transmittal folder under archive/<party>/staging/<…>. Modal lists
|
||||
// existing transmittal folders in the party's staging/ plus a "New
|
||||
// transmittal folder…" option that prompts for a ZDDC-conforming
|
||||
// name and mkdirs it before the move.
|
||||
//
|
||||
// Unstage: move a file from archive/<party>/staging/<transmittal>/
|
||||
// back to the user's archive/<party>/working/<email>/ home
|
||||
// (overridable).
|
||||
//
|
||||
// Both reuse the existing X-ZDDC-Op: move primitive — no new composite
|
||||
// endpoint is needed; the client just orchestrates one POST per file
|
||||
|
|
@ -26,32 +35,37 @@
|
|||
}
|
||||
|
||||
// ── Scope detection: path-shape, not cascade-content ──────────────
|
||||
// A file is stageable if its containing folder lives under
|
||||
// /<project>/working/<…>. Unstageable if it lives under
|
||||
// /<project>/staging/<transmittal>/<…>. Both are path-shape
|
||||
// queries — content/ACL is enforced server-side.
|
||||
// A file is stageable if its path matches
|
||||
// /<project>/archive/<party>/working/<…>. Unstageable if it
|
||||
// matches /<project>/archive/<party>/staging/<transmittal>/<…>.
|
||||
// Both are path-shape queries — content/ACL is enforced server-
|
||||
// side.
|
||||
|
||||
function projectAndSubtree(path) {
|
||||
// projectPartySlot returns { project, party, slot, rest } when
|
||||
// path matches /<project>/archive/<party>/<slot>/<rest…>, or
|
||||
// null on non-match.
|
||||
function projectPartySlot(path) {
|
||||
var rel = path.replace(/^\/+|\/+$/g, '').split('/');
|
||||
if (rel.length < 2) return null;
|
||||
return { project: rel[0], subtree: rel[1], rest: rel.slice(2) };
|
||||
if (rel.length < 4) return null;
|
||||
if (rel[1].toLowerCase() !== 'archive') return null;
|
||||
return { project: rel[0], party: rel[2], slot: rel[3], rest: rel.slice(4) };
|
||||
}
|
||||
|
||||
function isStageableFile(node) {
|
||||
if (!node || node.isDir || node.virtual) return false;
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return false;
|
||||
var p = projectAndSubtree(tree.pathFor(node));
|
||||
return !!(p && p.subtree === 'working' && p.rest.length >= 1);
|
||||
var p = projectPartySlot(tree.pathFor(node));
|
||||
return !!(p && p.slot === 'working' && p.rest.length >= 1);
|
||||
}
|
||||
function isUnstageableFile(node) {
|
||||
if (!node || node.isDir || node.virtual) return false;
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return false;
|
||||
var p = projectAndSubtree(tree.pathFor(node));
|
||||
// staging/<transmittal-folder>/<file> — at least one folder
|
||||
// segment between staging/ and the file.
|
||||
return !!(p && p.subtree === 'staging' && p.rest.length >= 2);
|
||||
var p = projectPartySlot(tree.pathFor(node));
|
||||
// archive/<party>/staging/<transmittal-folder>/<file> — at
|
||||
// least one folder segment between staging/ and the file.
|
||||
return !!(p && p.slot === 'staging' && p.rest.length >= 2);
|
||||
}
|
||||
|
||||
// ── Server helpers ─────────────────────────────────────────────────
|
||||
|
|
@ -69,8 +83,9 @@
|
|||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async function fetchStagingFolders(project) {
|
||||
var entries = await listDir('/' + project + '/staging/');
|
||||
async function fetchStagingFolders(project, party) {
|
||||
var entries = await listDir(
|
||||
'/' + project + '/archive/' + encodeURIComponent(party) + '/staging/');
|
||||
return entries
|
||||
.filter(function (e) { return e && e.isDir; })
|
||||
.map(function (e) { return e.name; });
|
||||
|
|
@ -256,14 +271,15 @@
|
|||
var tree = window.app.modules.tree;
|
||||
if (!tree) return;
|
||||
var srcUrl = tree.pathFor(node);
|
||||
var info = projectAndSubtree(srcUrl);
|
||||
if (!info || info.subtree !== 'working') {
|
||||
status('Stage applies only to files under working/.', 'error');
|
||||
var info = projectPartySlot(srcUrl);
|
||||
if (!info || info.slot !== 'working') {
|
||||
status('Stage applies only to files under archive/<party>/working/.', 'error');
|
||||
return;
|
||||
}
|
||||
var stagingBase = '/' + info.project + '/staging/';
|
||||
var stagingBase = '/' + info.project + '/archive/' +
|
||||
encodeURIComponent(info.party) + '/staging/';
|
||||
var folders;
|
||||
try { folders = await fetchStagingFolders(info.project); }
|
||||
try { folders = await fetchStagingFolders(info.project, info.party); }
|
||||
catch (e) {
|
||||
status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error');
|
||||
return;
|
||||
|
|
@ -290,20 +306,21 @@
|
|||
status((e && e.message) || 'move failed', 'error');
|
||||
return;
|
||||
}
|
||||
status('Staged ' + node.name + ' → staging/' + choice.folderName + '/ — reload to see the move.', 'success');
|
||||
status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/ — reload to see the move.', 'success');
|
||||
}
|
||||
|
||||
async function invokeUnstage(node) {
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return;
|
||||
var srcUrl = tree.pathFor(node);
|
||||
var info = projectAndSubtree(srcUrl);
|
||||
if (!info || info.subtree !== 'staging') {
|
||||
status('Unstage applies only to files under staging/.', 'error');
|
||||
var info = projectPartySlot(srcUrl);
|
||||
if (!info || info.slot !== 'staging') {
|
||||
status('Unstage applies only to files under archive/<party>/staging/.', 'error');
|
||||
return;
|
||||
}
|
||||
var email = await fetchSelfEmail();
|
||||
var defaultTarget = '/' + info.project + '/working/' + (email || '') + '/';
|
||||
var defaultTarget = '/' + info.project + '/archive/' +
|
||||
encodeURIComponent(info.party) + '/working/' + (email || '') + '/';
|
||||
var choice;
|
||||
try {
|
||||
choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget });
|
||||
|
|
|
|||
|
|
@ -218,6 +218,17 @@
|
|||
const col = colAt(c);
|
||||
if (!row || !col) return;
|
||||
|
||||
// $-prefixed columns are system-synthesized fields (e.g. the
|
||||
// `$party` source-party qualifier on project-rollup MDL/RSK
|
||||
// views). Their value is derived from the row's canonical
|
||||
// path on read and stripped before any write — editing them
|
||||
// would have no effect on disk, so suppress entry to edit
|
||||
// mode entirely. Selection still works for keyboard
|
||||
// navigation across the cell.
|
||||
if (typeof col.field === 'string' && col.field.charAt(0) === '$') {
|
||||
return;
|
||||
}
|
||||
|
||||
const propSchema = propertySchemaFor(col);
|
||||
|
||||
// Complex-type cells (nested object, generic array, oneOf)
|
||||
|
|
|
|||
|
|
@ -57,7 +57,17 @@
|
|||
// form-mode and never produce drafts here, so drafts only
|
||||
// contain primitive / string-array values that are safe to
|
||||
// overwrite the corresponding top-level field.
|
||||
return Object.assign({}, data || {}, drafts || {});
|
||||
//
|
||||
// $-prefixed keys are system-synthesised on read (e.g. `$party`
|
||||
// injected by the server's virtual-view handler on project-
|
||||
// rollup MDL/RSK rows). They are not part of the row's stored
|
||||
// YAML and would be rejected by the schema's additionalProperties
|
||||
// rule. Strip them before sending the write.
|
||||
const merged = Object.assign({}, data || {}, drafts || {});
|
||||
for (const k of Object.keys(merged)) {
|
||||
if (k.charAt(0) === '$') delete merged[k];
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function rowFromState(rowId) {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ test.describe('shared/logo.js', () => {
|
|||
});
|
||||
|
||||
test('wraps with href=/<project> when inside a project subtree', async ({ page }) => {
|
||||
await page.goto(`${baseUrl}/Project-1/working/casey/notes.md`, { waitUntil: 'load' });
|
||||
await page.goto(`${baseUrl}/Project-1/archive/Acme/working/casey/notes.md`, { waitUntil: 'load' });
|
||||
const got = await page.evaluate(() => {
|
||||
const a = document.querySelector('.app-header__logo-link');
|
||||
return a && a.getAttribute('href');
|
||||
|
|
@ -61,7 +61,7 @@ test.describe('shared/logo.js', () => {
|
|||
});
|
||||
|
||||
test('the wrapper carries an aria-label matching its target', async ({ page }) => {
|
||||
await page.goto(`${baseUrl}/Project-1/staging/`, { waitUntil: 'load' });
|
||||
await page.goto(`${baseUrl}/Project-1/archive/Acme/staging/`, { waitUntil: 'load' });
|
||||
const probe = await page.evaluate(() => {
|
||||
const a = document.querySelector('.app-header__logo-link');
|
||||
return a && {
|
||||
|
|
|
|||
|
|
@ -1088,6 +1088,31 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
}
|
||||
// Virtual folder-nav redirect. URLs of the shape
|
||||
// /<project>/{working,staging,reviewing}/<party>[/...]
|
||||
// 302 to /<project>/archive/<party>/<slot>[/...] — the
|
||||
// canonical physical path. The per-party folder-nav
|
||||
// virtual itself has no on-disk presence; the redirect
|
||||
// hands the client off to the real address so subsequent
|
||||
// navigation, sharing, and bookmarks stay canonical.
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
if vv := zddc.ResolveVirtualView(cfg.Root, urlPath); vv.Resolved && vv.Kind == zddc.VirtualViewFolderNavRedir {
|
||||
target := vv.CanonicalURL
|
||||
// Preserve trailing slash from the request, since
|
||||
// the canonical URL is a directory.
|
||||
if strings.HasSuffix(urlPath, "/") && !strings.HasSuffix(target, "/") {
|
||||
target += "/"
|
||||
}
|
||||
// Preserve query string verbatim — clients
|
||||
// passing ?hidden=1 etc. should land at the same
|
||||
// query on the canonical URL.
|
||||
if q := r.URL.RawQuery; q != "" {
|
||||
target += "?" + q
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
// File doesn't exist at this path. Before falling through to
|
||||
// app-HTML routing or 404, check the two virtual-file-extension
|
||||
// shapes that ZDDC exposes through the listing convention:
|
||||
|
|
@ -1140,10 +1165,11 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
}
|
||||
}
|
||||
}
|
||||
// reviewing/ is no longer a virtual aggregator — it's a normal
|
||||
// directory under each project, populated by the Plan Review
|
||||
// composite endpoint with physical workflow folders. Falls
|
||||
// through to the canonical-folder block below.
|
||||
// (Top-level <project>/{working,staging,reviewing} URLs
|
||||
// resolve as folder-nav virtuals — the per-party redirect
|
||||
// is handled above; the bare top-level URL falls through
|
||||
// to ServeDirectory, where ListDirectory synthesises the
|
||||
// folder-nav listing from ListPartyDirsInSlot.)
|
||||
//
|
||||
// Virtual received/ window. <workflow>/received/[...] is a
|
||||
// synthetic view onto the canonical received/<tracking>/
|
||||
|
|
|
|||
|
|
@ -201,17 +201,18 @@ func TestDispatchAppsResolution(t *testing.T) {
|
|||
}
|
||||
|
||||
// Folder availability rules: classifier should NOT be served at root
|
||||
// (root has no Incoming/Working/Staging ancestor), but SHOULD work in
|
||||
// /Project-A/Working/.
|
||||
// (root has no per-party working/staging/incoming ancestor), but
|
||||
// SHOULD work at /Project-A/archive/<party>/working/ where the per-
|
||||
// party cascade declares classifier available.
|
||||
rec5 := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil))
|
||||
if rec5.Code != http.StatusNotFound {
|
||||
t.Errorf("/classifier.html at root: status=%d, want 404 (not in Incoming/Working/Staging)", rec5.Code)
|
||||
t.Errorf("/classifier.html at root: status=%d, want 404 (not in per-party working/staging/incoming)", rec5.Code)
|
||||
}
|
||||
rec6 := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/Working/classifier.html", nil))
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/archive/Acme/Working/classifier.html", nil))
|
||||
if rec6.Code != http.StatusOK {
|
||||
t.Errorf("/Project-A/Working/classifier.html: status=%d, want 200", rec6.Code)
|
||||
t.Errorf("/Project-A/archive/Acme/Working/classifier.html: status=%d, want 200", rec6.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -617,21 +618,18 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// No-trailing-slash form on a canonical folder → default app
|
||||
// (browse for working/+reviewing/, transmittal for staging/,
|
||||
// archive for archive/). Mirror of the existing "no-slash →
|
||||
// default app" behavior at the IsDir branch, extended to cover
|
||||
// the case where the folder doesn't exist on disk yet.
|
||||
// No-trailing-slash form on a canonical folder → default app.
|
||||
// Under the reshape, the project-root staging/reviewing/working
|
||||
// URLs are folder-nav virtuals served by browse (the per-party
|
||||
// transmittal default lives at archive/<party>/staging/). archive/
|
||||
// is still the archive tool.
|
||||
noSlashDefaultApp := []struct {
|
||||
stage string
|
||||
expect string // substring that should appear in the response body
|
||||
}{
|
||||
{"working", "ZDDC Browse"},
|
||||
{"staging", "ZDDC Transmittal"},
|
||||
{"staging", "ZDDC Browse"},
|
||||
{"archive", "ZDDC Archive"},
|
||||
// reviewing/ also routes to browse (markdown editor lives
|
||||
// inside it now); the polyfill follows the virtual aggregator's
|
||||
// listing into canonical archive/+staging paths from there.
|
||||
{"reviewing", "ZDDC Browse"},
|
||||
}
|
||||
for _, tc := range noSlashDefaultApp {
|
||||
|
|
|
|||
|
|
@ -37,23 +37,36 @@ func AppAvailableAt(root, requestDir, app string) bool {
|
|||
// which app to serve at a directory URL with no trailing slash —
|
||||
// trailing-slash URLs serve the browse app for any directory.
|
||||
//
|
||||
// Rules (case-insensitive on canonical folder names):
|
||||
// Rules (case-insensitive on canonical folder names), under the
|
||||
// May 2026 reshape:
|
||||
//
|
||||
// - <project>/archive/<party>/mdl/... → "tables"
|
||||
// - <project>/archive/ → "archive"
|
||||
// - <project>/archive/<party>/... → "archive"
|
||||
// - <project>/staging/... → "transmittal"
|
||||
// - <project>/working/... → "browse" (hosts the
|
||||
// markdown editor plugin)
|
||||
// - <project>/reviewing/... → "browse" (operates on the
|
||||
// virtual aggregator listing)
|
||||
// - any other directory → "" (no default)
|
||||
// - <project>/archive/<party>/{mdl,rsk}/... → "tables"
|
||||
// - <project>/archive/<party>/staging/... → "transmittal"
|
||||
// - <project>/archive/<party>/{working,reviewing}/...
|
||||
// → "browse" (hosts the
|
||||
// markdown editor plugin)
|
||||
// - <project>/archive/<party>/incoming/... → "classifier"
|
||||
// - <project>/archive/<party>/{received,issued}/...
|
||||
// → "archive"
|
||||
// - <project>/archive/ → "archive"
|
||||
// - <project>/{ssr,mdl,rsk} → "tables" (project-
|
||||
// level rollup virtuals
|
||||
// with synthesized
|
||||
// $party column)
|
||||
// - <project>/{working,staging,reviewing} → "browse" (project-
|
||||
// level folder-nav
|
||||
// virtuals — per-party
|
||||
// URLs 302 to the
|
||||
// canonical archive/
|
||||
// <party>/<slot>/)
|
||||
// - any other directory → "" (no default)
|
||||
//
|
||||
// The mdl rule wins over the broader archive rule because the table
|
||||
// editor is a more specific surface for browsing planned deliverables
|
||||
// than the archive index. Note: the dir at archive/<party>/mdl/
|
||||
// itself IS the table — its table.yaml + form.yaml + row YAMLs all
|
||||
// live there together (self-contained directory).
|
||||
// The {mdl,rsk} rule wins over the broader archive rule because the
|
||||
// table editor is a more specific surface for browsing planned
|
||||
// deliverables than the archive index. Note: the dir at
|
||||
// archive/<party>/mdl/ itself IS the table — its table.yaml +
|
||||
// form.yaml + row YAMLs all live there together (self-contained
|
||||
// directory).
|
||||
//
|
||||
// requestDir and root are absolute filesystem paths; requestDir must
|
||||
// be under root (otherwise "" is returned).
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@ func TestAppAvailableAt(t *testing.T) {
|
|||
{root, "landing", true},
|
||||
{root + "/Project-A", "landing", false},
|
||||
|
||||
// classifier: working/, staging/, archive/<party>/incoming/ and subtrees
|
||||
// classifier: per-party working/, staging/, incoming/ subtrees
|
||||
{root, "classifier", false},
|
||||
{root + "/Project-A", "classifier", false},
|
||||
{root + "/Project-A/working", "classifier", true},
|
||||
{root + "/Project-A/working/deep/nested/path", "classifier", true},
|
||||
{root + "/Project-A/staging", "classifier", true},
|
||||
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "classifier", true},
|
||||
{root + "/Project-A/archive/ACME/working", "classifier", true},
|
||||
{root + "/Project-A/archive/ACME/working/deep/nested/path", "classifier", true},
|
||||
{root + "/Project-A/archive/ACME/staging", "classifier", true},
|
||||
{root + "/Project-A/archive/ACME/staging/2026-06-15_x (DFT) - y", "classifier", true},
|
||||
{root + "/Project-A/archive/ACME/incoming", "classifier", true},
|
||||
{root + "/Project-A/archive/ACME/incoming/sub", "classifier", true},
|
||||
{root + "/Project-A/archive/ACME/received", "classifier", false},
|
||||
|
|
@ -37,20 +37,20 @@ func TestAppAvailableAt(t *testing.T) {
|
|||
|
||||
// browse: universal — every directory has browse available
|
||||
// (it's in the embedded-defaults baseline available_tools).
|
||||
{root + "/Project-A/working", "browse", true},
|
||||
{root + "/Project-A/working/sub", "browse", true},
|
||||
{root + "/Project-A/staging", "browse", true},
|
||||
{root + "/Project-A/archive/ACME/working", "browse", true},
|
||||
{root + "/Project-A/archive/ACME/working/sub", "browse", true},
|
||||
{root + "/Project-A/archive/ACME/staging", "browse", true},
|
||||
{root + "/Project-A/archive/ACME/incoming", "browse", true},
|
||||
|
||||
// transmittal: staging/ only
|
||||
{root + "/Project-A/staging", "transmittal", true},
|
||||
{root + "/Project-A/staging/sub", "transmittal", true},
|
||||
{root + "/Project-A/working", "transmittal", false},
|
||||
// transmittal: per-party staging/ only
|
||||
{root + "/Project-A/archive/ACME/staging", "transmittal", true},
|
||||
{root + "/Project-A/archive/ACME/staging/sub", "transmittal", true},
|
||||
{root + "/Project-A/archive/ACME/working", "transmittal", false},
|
||||
{root + "/Project-A/archive/ACME/issued", "transmittal", false},
|
||||
|
||||
// case-fold: any case of canonical names matches
|
||||
{root + "/Project-A/Staging", "transmittal", true},
|
||||
{root + "/Project-A/STAGING", "transmittal", true},
|
||||
{root + "/Project-A/archive/ACME/Staging", "transmittal", true},
|
||||
{root + "/Project-A/archive/ACME/STAGING", "transmittal", true},
|
||||
{root + "/Project-A/archive/ACME/Incoming", "classifier", true},
|
||||
{root + "/Project-A/Archive/ACME/incoming", "classifier", true},
|
||||
|
||||
|
|
@ -79,12 +79,20 @@ func TestDefaultAppAt(t *testing.T) {
|
|||
// Bare project root: no default. Trailing-slash URL serves browse;
|
||||
// no-slash falls through to the redirect.
|
||||
{root + "/Project-A", ""},
|
||||
// Canonical project-root folders.
|
||||
// Project-level virtual aggregators (sibling to archive/).
|
||||
{root + "/Project-A/working", "browse"},
|
||||
{root + "/Project-A/working/alice@example.com", "browse"},
|
||||
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "browse"},
|
||||
{root + "/Project-A/staging", "transmittal"},
|
||||
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"},
|
||||
{root + "/Project-A/staging", "browse"},
|
||||
{root + "/Project-A/reviewing", "browse"},
|
||||
{root + "/Project-A/ssr", "tables"},
|
||||
{root + "/Project-A/mdl", "tables"},
|
||||
{root + "/Project-A/rsk", "tables"},
|
||||
// Per-party lifecycle slots (the real physical homes).
|
||||
{root + "/Project-A/archive/Acme/working", "browse"},
|
||||
{root + "/Project-A/archive/Acme/working/alice@example.com", "browse"},
|
||||
{root + "/Project-A/archive/Acme/working/2026-06-15_x (DFT) - y", "browse"},
|
||||
{root + "/Project-A/archive/Acme/staging", "transmittal"},
|
||||
{root + "/Project-A/archive/Acme/staging/2026-06-15_x (DFT) - y", "transmittal"},
|
||||
{root + "/Project-A/archive/Acme/reviewing", "browse"},
|
||||
// archive: at the archive root, party folders default to archive.
|
||||
// Per-party subfolders override per their function:
|
||||
// incoming → classifier (the bulk-rename workflow)
|
||||
|
|
@ -94,20 +102,15 @@ func TestDefaultAppAt(t *testing.T) {
|
|||
{root + "/Project-A/archive/Acme/incoming", "classifier"},
|
||||
{root + "/Project-A/archive/Acme/issued", "archive"},
|
||||
{root + "/Project-A/archive/Acme/received", "archive"},
|
||||
// mdl wins over the broader archive rule.
|
||||
// mdl/rsk win over the broader archive rule.
|
||||
{root + "/Project-A/archive/Acme/mdl", "tables"},
|
||||
{root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"},
|
||||
// reviewing/ is virtual; browse hosts the markdown editor that
|
||||
// renders responses (the polyfill follows the listing's
|
||||
// canonical URLs into archive/ and staging/ for the actual
|
||||
// files).
|
||||
{root + "/Project-A/reviewing", "browse"},
|
||||
{root + "/Project-A/reviewing/123-EM-SUB-0001", "browse"},
|
||||
{root + "/Project-A/archive/Acme/rsk", "tables"},
|
||||
// Random non-canonical folder names → no default.
|
||||
{root + "/Project-A/scratch", ""},
|
||||
// Case-fold on canonical names.
|
||||
{root + "/Project-A/Working", "browse"},
|
||||
{root + "/Project-A/STAGING", "transmittal"},
|
||||
{root + "/Project-A/archive/Acme/Working", "browse"},
|
||||
{root + "/Project-A/archive/Acme/STAGING", "transmittal"},
|
||||
{root + "/Project-A/Archive/Acme/MDL", "tables"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
|
|
|||
|
|
@ -195,32 +195,39 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
result = append(result, fi)
|
||||
}
|
||||
|
||||
// Per-user virtual home: when listing <project>/working/ for an
|
||||
// authenticated viewer, surface a synthetic <viewer-email>/ entry if
|
||||
// no real folder of any case variant already exists for them. A
|
||||
// first write to that path materialises a real folder with auto-own
|
||||
// .zddc; subsequent listings drop the synthetic entry naturally.
|
||||
// Per-user virtual home: when listing
|
||||
// <project>/archive/<party>/working/ for an authenticated viewer,
|
||||
// surface a synthetic <viewer-email>/ entry if no real folder of
|
||||
// any case variant already exists for them. A first write to that
|
||||
// path materialises a real folder with auto-own .zddc; subsequent
|
||||
// listings drop the synthetic entry naturally.
|
||||
if syn, ok := virtualUserHomeEntry(fsRoot, dirPath, userEmail, baseURL, result); ok {
|
||||
result = append(result, syn)
|
||||
}
|
||||
|
||||
// At a project root, surface the four canonical project folders
|
||||
// (archive/working/staging/reviewing) as virtual entries when no
|
||||
// on-disk variant exists in any case. The browse client previously
|
||||
// did this client-side; moving it server-side lets the directory's
|
||||
// `display:` map apply to virtual entries the same way it applies
|
||||
// to real ones.
|
||||
// At a project root, surface the cascade-declared top-level
|
||||
// folders (archive plus the six virtual aggregators) as virtual
|
||||
// entries when no on-disk variant exists. The browse client
|
||||
// previously did this client-side; moving it server-side lets the
|
||||
// directory's `display:` map apply to virtual entries the same
|
||||
// way it applies to real ones.
|
||||
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.
|
||||
// Project-level virtual views:
|
||||
//
|
||||
// Row rollups (ssr/mdl/rsk) — synthesize 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
|
||||
// come from main.go IsDefaultSpec fallback; row reads go through
|
||||
// handler.ServeVirtualViewRow which path-injects name/$party.
|
||||
//
|
||||
// Folder-nav (working/staging/reviewing) — synthesize one
|
||||
// IsDir=true entry per party whose archive/<party>/<slot>/ has
|
||||
// non-empty content (in-flight filter). The browse client
|
||||
// follows a click through to the virtual URL
|
||||
// <project>/<slot>/<party>/ which the dispatcher 302s to the
|
||||
// canonical archive/<party>/<slot>/.
|
||||
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 {
|
||||
|
|
@ -252,6 +259,19 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
Writable: writable,
|
||||
})
|
||||
}
|
||||
appendVirtualPartyDir := func(party, partyAbs string) {
|
||||
dirURL := baseURL + url.PathEscape(party) + "/"
|
||||
chain := chainFor(partyAbs)
|
||||
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, dirURL); !allowed {
|
||||
return
|
||||
}
|
||||
result = append(result, listing.FileInfo{
|
||||
Name: party + "/",
|
||||
URL: dirURL,
|
||||
IsDir: true,
|
||||
Virtual: true,
|
||||
})
|
||||
}
|
||||
|
||||
switch vv.Slot {
|
||||
case "ssr":
|
||||
|
|
@ -266,12 +286,23 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
partyAbs := filepath.Join(vv.ProjectAbs, "archive", row.Party)
|
||||
appendVirtualRow(row.SyntheticName, partyAbs)
|
||||
}
|
||||
case "working", "staging", "reviewing":
|
||||
parties, _ := zddc.ListPartyDirsInSlot(fsRoot, vv.ProjectAbs, vv.Slot)
|
||||
for _, party := range parties {
|
||||
partyAbs := filepath.Join(vv.ProjectAbs, "archive", party)
|
||||
appendVirtualPartyDir(party, partyAbs)
|
||||
}
|
||||
}
|
||||
|
||||
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},
|
||||
)
|
||||
// Row rollups carry synthetic spec entries so the tables tool
|
||||
// can walkServer them. Folder-nav virtuals don't need spec
|
||||
// files — they're just party listings rendered by browse.
|
||||
if zddc.IsRowSlot(vv.Slot) {
|
||||
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
|
||||
|
|
@ -393,11 +424,18 @@ func virtualCanonicalFolders(fsRoot, absDir, baseURL string,
|
|||
}
|
||||
|
||||
// virtualUserHomeEntry returns the synthetic <viewer-email>/ entry that
|
||||
// should be appended to a working/ listing, or (zero, false) when no
|
||||
// synthetic entry applies.
|
||||
// should be appended to a per-party working/ listing, or (zero, false)
|
||||
// when no synthetic entry applies.
|
||||
//
|
||||
// Under the canonical layout, per-user homes live at
|
||||
// <project>/archive/<party>/working/<email>/ (depth-4 working slot
|
||||
// inside the party folder). The synthetic entry fires when dirPath
|
||||
// case-folds to <project>/archive/<party>/working and the viewer has
|
||||
// no real home folder yet.
|
||||
//
|
||||
// Conditions for the entry to fire:
|
||||
// - dirPath case-folds to <project>/working at depth-2 of fsRoot
|
||||
// - dirPath case-folds to <project>/archive/<party>/working at
|
||||
// depth-4 of fsRoot
|
||||
// - viewerEmail is non-empty
|
||||
// - real does not already contain a directory entry that case-folds
|
||||
// to viewerEmail (so a materialised home doesn't get duplicated)
|
||||
|
|
@ -407,7 +445,9 @@ func virtualUserHomeEntry(fsRoot, dirPath, viewerEmail, baseURL string, real []l
|
|||
}
|
||||
rel := strings.Trim(filepath.ToSlash(dirPath), "/")
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[1], "working") {
|
||||
if len(parts) != 4 ||
|
||||
!strings.EqualFold(parts[1], "archive") ||
|
||||
!strings.EqualFold(parts[3], "working") {
|
||||
return listing.FileInfo{}, false
|
||||
}
|
||||
for _, fi := range real {
|
||||
|
|
|
|||
|
|
@ -21,14 +21,20 @@ func setupTreeRoot(t *testing.T) string {
|
|||
return root
|
||||
}
|
||||
|
||||
// Per-user homes now live at archive/<party>/working/<email>/ (depth-
|
||||
// 4). The virtual entry fires when listing that path for a viewer
|
||||
// whose home doesn't yet exist on disk.
|
||||
|
||||
func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "working"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, false)
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/archive/Acme/working", "alice@example.com",
|
||||
"/Proj/archive/Acme/working/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
|
@ -51,12 +57,14 @@ func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
|
|||
func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
// A real folder exists for the viewer (any case).
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "Alice@Example.com"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working", "Alice@Example.com"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, false)
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/archive/Acme/working", "alice@example.com",
|
||||
"/Proj/archive/Acme/working/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
|
@ -69,12 +77,14 @@ func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
|
|||
|
||||
func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "working"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/", false, false)
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/archive/Acme/working", "" /* no viewer */,
|
||||
"/Proj/archive/Acme/working/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
|
@ -87,12 +97,14 @@ func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
|
|||
|
||||
func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "staging"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "staging"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/", false, false)
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/archive/Acme/staging", "alice@example.com",
|
||||
"/Proj/archive/Acme/staging/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
|
@ -105,15 +117,15 @@ func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
|
|||
|
||||
func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
// Listing inside working/ at depth 3+ — no synthetic entry should fire.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "alice@example.com"), 0o755); err != nil {
|
||||
// Listing inside working/<email>/ — no synthetic entry should fire.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/working/alice@example.com", "alice@example.com",
|
||||
"/Proj/working/alice@example.com/", false, false)
|
||||
"Proj/archive/Acme/working/alice@example.com", "alice@example.com",
|
||||
"/Proj/archive/Acme/working/alice@example.com/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
|
@ -126,13 +138,15 @@ func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
|
|||
|
||||
func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
// Pre-existing PascalCase Working/.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "Working"), 0o755); err != nil {
|
||||
// Pre-existing PascalCase Working/ under archive/<party>/.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "Working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/", false, false)
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/archive/Acme/Working", "alice@example.com",
|
||||
"/Proj/archive/Acme/Working/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
|
@ -147,14 +161,17 @@ func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Listing a canonical project folder that doesn't exist on disk yet
|
||||
// Listing a canonical-folder path that doesn't exist on disk yet
|
||||
// returns an empty slice instead of os.ErrNotExist. The stage-strip
|
||||
// nav links into <project>/working/ etc. unconditionally; this keeps
|
||||
// fresh projects (no working/ on disk yet) from 404'ing.
|
||||
// nav links into <project>/archive/ etc. unconditionally; this keeps
|
||||
// fresh projects from 404'ing.
|
||||
//
|
||||
// The synthetic per-user home entry fires for the in-party working
|
||||
// slot; other canonical slots return a plain empty listing.
|
||||
func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
// Proj exists but Proj/working/ does NOT.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
|
||||
// Proj exists; the party folder skeleton does not.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"),
|
||||
|
|
@ -163,29 +180,31 @@ func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
|
|||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
for _, stage := range []string{"working", "staging", "reviewing", "archive"} {
|
||||
for _, stage := range []string{"working", "staging", "reviewing", "incoming"} {
|
||||
dirPath := "Proj/archive/Acme/" + stage
|
||||
baseURL := "/" + dirPath + "/"
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false, false)
|
||||
dirPath, "alice@example.com", baseURL, false, false)
|
||||
if err != nil {
|
||||
t.Errorf("ListDirectory(Proj/%s) on missing dir: err = %v, want nil", stage, err)
|
||||
t.Errorf("ListDirectory(%s) on missing dir: err = %v, want nil", dirPath, err)
|
||||
continue
|
||||
}
|
||||
// working/ surfaces a synthetic <viewer-email>/ entry; the others
|
||||
// should be a flat empty listing.
|
||||
// working/ surfaces a synthetic <viewer-email>/ entry; the
|
||||
// others should be a flat empty listing.
|
||||
if stage == "working" {
|
||||
if len(got) != 1 || !got[0].Virtual {
|
||||
t.Errorf("ListDirectory(Proj/working) on missing dir: got %+v, want only the virtual home entry", got)
|
||||
t.Errorf("ListDirectory(%s) on missing dir: got %+v, want only the virtual home entry", dirPath, got)
|
||||
}
|
||||
} else {
|
||||
if len(got) != 0 {
|
||||
t.Errorf("ListDirectory(Proj/%s) on missing dir: got %+v, want empty", stage, got)
|
||||
t.Errorf("ListDirectory(%s) on missing dir: got %+v, want empty", dirPath, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-canonical paths still 404 (return os.ErrNotExist) — the fallback
|
||||
// only applies to the four canonical project-root folders.
|
||||
// only applies to cascade-declared paths.
|
||||
func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
|
||||
|
|
@ -204,3 +223,37 @@ func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
|
|||
t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Project-level folder-nav virtual lists only the parties that have
|
||||
// non-empty content in the slot. Empty/missing party slots are
|
||||
// filtered out.
|
||||
func TestListDirectory_VirtualFolderNav_FiltersInFlight(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
// Acme has a populated working/; Beta is scaffolded but empty.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Proj", "archive", "Acme", "working", "draft.md"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Beta", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/working", "alice@example.com", "/Proj/working/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
var partyDirs []string
|
||||
for _, fi := range got {
|
||||
if fi.IsDir && fi.Virtual {
|
||||
partyDirs = append(partyDirs, fi.Name)
|
||||
}
|
||||
}
|
||||
want := []string{"Acme/"}
|
||||
if len(partyDirs) != 1 || partyDirs[0] != want[0] {
|
||||
t.Errorf("project-level folder-nav listing = %v, want %v", partyDirs, want)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ import (
|
|||
// invariantsFixture sets up a synthetic ZDDC root with:
|
||||
//
|
||||
// - admin@example.com — root super-admin
|
||||
// - alice@example.com — subtree admin of Project-1/working (via per-dir
|
||||
// .zddc admins:) — used to test subtree scope
|
||||
// - alice@example.com — subtree admin of Project-1/archive/Acme/working
|
||||
// (via per-dir .zddc admins:) — used to test
|
||||
// subtree scope
|
||||
// - bob@example.com — document_controller role member (gets WORM cr
|
||||
// on received/ + issued/ via cascade defaults)
|
||||
// - eve@example.com — non-admin, project_team only (read-only across
|
||||
|
|
@ -47,7 +48,7 @@ func invariantsFixture(t *testing.T) (config.Config, string) {
|
|||
" project_team:\n members: [\"*@example.com\"]\n")
|
||||
|
||||
for _, d := range []string{
|
||||
"Project-1/working/eve@example.com",
|
||||
"Project-1/archive/Acme/working/eve@example.com",
|
||||
"Project-1/archive/Acme/received/Acme-0042",
|
||||
"Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test",
|
||||
} {
|
||||
|
|
@ -56,14 +57,14 @@ func invariantsFixture(t *testing.T) (config.Config, string) {
|
|||
}
|
||||
}
|
||||
|
||||
// Subtree-admin grant: alice administers Project-1/working/.
|
||||
// Subtree-admin grant: alice administers Project-1/archive/Acme/working/.
|
||||
mustWriteHelper(t,
|
||||
filepath.Join(root, "Project-1/working/.zddc"),
|
||||
filepath.Join(root, "Project-1/archive/Acme/working/.zddc"),
|
||||
"admins:\n - alice@example.com\n")
|
||||
|
||||
// Files to act on.
|
||||
mustWriteHelper(t,
|
||||
filepath.Join(root, "Project-1/working/eve@example.com/draft.md"),
|
||||
filepath.Join(root, "Project-1/archive/Acme/working/eve@example.com/draft.md"),
|
||||
"# eve's draft\n")
|
||||
mustWriteHelper(t,
|
||||
filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Test.pdf"),
|
||||
|
|
@ -114,7 +115,7 @@ func TestInvariant_UnelevatedAdminCannotEditZddc(t *testing.T) {
|
|||
// Exercised at the HTTP boundary: a PUT to .zddc from an un-elevated
|
||||
// super-admin must return Forbidden.
|
||||
cfg, _ := invariantsFixture(t)
|
||||
target := "/Project-1/working/.zddc"
|
||||
target := "/Project-1/archive/Acme/working/.zddc"
|
||||
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("title: mutated\n"), "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("un-elevated admin .zddc write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
|
|
@ -126,7 +127,7 @@ func TestInvariant_ElevatedAdminCanEditZddc(t *testing.T) {
|
|||
// .zddc. The decider's IsActiveAdmin short-circuit fires in
|
||||
// AllowActionFromChainP and the file API write proceeds.
|
||||
cfg, _ := invariantsFixture(t)
|
||||
target := "/Project-1/working/.zddc"
|
||||
target := "/Project-1/archive/Acme/working/.zddc"
|
||||
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", true, []byte("title: elevated edit\n"), "")
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
|
||||
t.Fatalf("elevated admin .zddc write blocked: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
|
|
@ -148,9 +149,9 @@ func TestInvariant_ElevatedAdminBypassesWorm(t *testing.T) {
|
|||
|
||||
func TestInvariant_ElevatedSubtreeAdminWritesInScope(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
target := "/Project-1/working/eve@example.com/draft.md"
|
||||
target := "/Project-1/archive/Acme/working/eve@example.com/draft.md"
|
||||
rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# alice override\n"), "")
|
||||
// alice is subtree admin of Project-1/working/ — should override eve's
|
||||
// alice is subtree admin of Project-1/archive/Acme/working/ — should override eve's
|
||||
// fenced auto-own and write through.
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
|
||||
t.Fatalf("elevated subtree admin write in scope blocked: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
|
|
@ -159,7 +160,7 @@ func TestInvariant_ElevatedSubtreeAdminWritesInScope(t *testing.T) {
|
|||
|
||||
func TestInvariant_ElevatedSubtreeAdminBlockedOutsideScope(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
// alice is subtree admin of /Project-1/working/, NOT of /Project-1/archive/.
|
||||
// alice is subtree admin of /Project-1/archive/Acme/working/, NOT of /Project-1/archive/.
|
||||
target := "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md"
|
||||
rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# out-of-scope\n"), "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
|
|
@ -178,7 +179,7 @@ func TestInvariant_ElevatedSubtreeAdminBlockedOutsideScope(t *testing.T) {
|
|||
func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
p := zddc.Principal{Email: "alice@example.com", Elevated: true}
|
||||
dir := filepath.Join(cfg.Root, "Project-1/working")
|
||||
dir := filepath.Join(cfg.Root, "Project-1/archive/Acme/working")
|
||||
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("EffectivePolicy: %v", err)
|
||||
|
|
@ -191,7 +192,7 @@ func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) {
|
|||
func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
p := zddc.Principal{Email: "alice@example.com", Elevated: true}
|
||||
dir := filepath.Join(cfg.Root, "Project-1/working/eve@example.com")
|
||||
dir := filepath.Join(cfg.Root, "Project-1/archive/Acme/working/eve@example.com")
|
||||
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("EffectivePolicy: %v", err)
|
||||
|
|
@ -205,7 +206,7 @@ func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) {
|
|||
|
||||
func TestInvariant_EmptyEmailHasNoAuthority(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
target := "/Project-1/working/eve@example.com/draft.md"
|
||||
target := "/Project-1/archive/Acme/working/eve@example.com/draft.md"
|
||||
rec := doReq(cfg, http.MethodPut, target, "", true, []byte("# anon\n"), "")
|
||||
if rec.Code != http.StatusForbidden && rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("empty-email write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
|
|
@ -295,7 +296,7 @@ func TestInvariant_ForwardAuthEndpointGatesOnAdminsList(t *testing.T) {
|
|||
// - /Project-1/.zddc — project file (no on-disk .zddc;
|
||||
// write must materialise it; root
|
||||
// admins still govern via cascade)
|
||||
// - /Project-1/working/.zddc — subtree file; alice administers
|
||||
// - /Project-1/archive/Acme/working/.zddc — subtree file; alice administers
|
||||
// this subtree via its own admins:
|
||||
// list (so alice's write doesn't
|
||||
// require root-admin authority).
|
||||
|
|
@ -340,12 +341,12 @@ func TestInvariant_ZddcPutMatrix(t *testing.T) {
|
|||
{"non-admin → project .zddc", "/Project-1/.zddc", nonAdmin, den},
|
||||
|
||||
// Subtree .zddc (alice administers this subtree)
|
||||
{"root admin elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminElevated, ok},
|
||||
{"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, den},
|
||||
{"subtree admin elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, ok},
|
||||
{"subtree admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, den},
|
||||
{"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, den},
|
||||
{"anonymous → subtree .zddc", "/Project-1/working/.zddc", anon, den},
|
||||
{"root admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminElevated, ok},
|
||||
{"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, den},
|
||||
{"subtree admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminElevated, ok},
|
||||
{"subtree admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, den},
|
||||
{"non-admin → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", nonAdmin, den},
|
||||
{"anonymous → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", anon, den},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
|
@ -386,11 +387,11 @@ func TestInvariant_ZddcDeleteMatrix(t *testing.T) {
|
|||
who principal
|
||||
want int
|
||||
}{
|
||||
{"root admin elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminElevated, http.StatusNoContent},
|
||||
{"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, http.StatusForbidden},
|
||||
{"subtree admin elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, http.StatusNoContent},
|
||||
{"subtree admin un-elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden},
|
||||
{"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, http.StatusForbidden},
|
||||
{"root admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminElevated, http.StatusNoContent},
|
||||
{"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, http.StatusForbidden},
|
||||
{"subtree admin elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminElevated, http.StatusNoContent},
|
||||
{"subtree admin un-elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden},
|
||||
{"non-admin → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", nonAdmin, http.StatusForbidden},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
|
@ -423,14 +424,14 @@ func TestInvariant_UnelevatedAdminNoSilentBypass(t *testing.T) {
|
|||
probes := []op{
|
||||
// .zddc writes (ActionAdmin)
|
||||
{http.MethodPut, "/.zddc", []byte("title: x\n"), ""},
|
||||
{http.MethodPut, "/Project-1/working/.zddc", []byte("title: x\n"), ""},
|
||||
{http.MethodDelete, "/Project-1/working/.zddc", nil, ""},
|
||||
{http.MethodPut, "/Project-1/archive/Acme/working/.zddc", []byte("title: x\n"), ""},
|
||||
{http.MethodDelete, "/Project-1/archive/Acme/working/.zddc", nil, ""},
|
||||
// WORM writes (ActionWrite / ActionCreate stripped)
|
||||
{http.MethodPut, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", []byte("# mutate\n"), ""},
|
||||
{http.MethodPut, "/Project-1/archive/Acme/received/Acme-0042/new.pdf", []byte("%PDF\n"), ""},
|
||||
{http.MethodDelete, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", nil, ""},
|
||||
// Regular write into someone else's working/ home (no ACL grant)
|
||||
{http.MethodPut, "/Project-1/working/eve@example.com/draft.md", []byte("# steal\n"), ""},
|
||||
{http.MethodPut, "/Project-1/archive/Acme/working/eve@example.com/draft.md", []byte("# steal\n"), ""},
|
||||
}
|
||||
|
||||
admins := []struct {
|
||||
|
|
|
|||
|
|
@ -4,21 +4,23 @@
|
|||
#
|
||||
# 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).
|
||||
# at <project>/archive/<party>/mdl/<file>.yaml; the leading `$party`
|
||||
# column is the server-synthesized source-party identity (path-
|
||||
# injected on read, not stored in the YAML). The `$` sigil marks it
|
||||
# as system-managed — tables tool renders read-only and strips it
|
||||
# before submitting a row write.
|
||||
#
|
||||
# + Add row IS enabled here: the `party` column doubles as the
|
||||
# routing key — the server reads the submitted `party` field, finds
|
||||
# the matching <project>/archive/<party>/ folder, and writes the row
|
||||
# inside its mdl/ subfolder. The party folder must already exist
|
||||
# (create it via the SSR view).
|
||||
# + Add row IS enabled here: the form schema's `party` field doubles
|
||||
# as the routing key — the server reads the submitted `party` field,
|
||||
# finds the matching <project>/archive/<party>/ folder, and writes
|
||||
# the row inside its mdl/ subfolder. The party folder must already
|
||||
# exist (create it via the SSR view).
|
||||
|
||||
title: Project Deliverables (all parties)
|
||||
description: Every deliverable across all parties under archive/. Click a row to edit; + Add row uses the Package column to route the new row to the matching archive/<party>/mdl/ folder.
|
||||
|
||||
columns:
|
||||
- field: party
|
||||
- field: $party
|
||||
title: Package
|
||||
width: 7em
|
||||
- field: originator
|
||||
|
|
@ -64,5 +66,5 @@ columns:
|
|||
|
||||
defaults:
|
||||
sort:
|
||||
- { field: party, dir: asc }
|
||||
- { field: $party, dir: asc }
|
||||
- { field: plannedDate, dir: asc }
|
||||
|
|
|
|||
|
|
@ -3,21 +3,23 @@
|
|||
#
|
||||
# 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).
|
||||
# at <project>/archive/<party>/rsk/<file>.yaml; the leading `$party`
|
||||
# column is the server-synthesized source-party identity (path-
|
||||
# injected on read, not stored in the YAML). The `$` sigil marks it
|
||||
# as system-managed — tables tool renders read-only and strips it
|
||||
# before submitting a row write.
|
||||
#
|
||||
# + Add row IS enabled here: the `party` column doubles as the
|
||||
# routing key — the server reads the submitted `party` field, finds
|
||||
# the matching <project>/archive/<party>/ folder, and writes the row
|
||||
# inside its rsk/ subfolder. The party folder must already exist
|
||||
# (create it via the SSR view).
|
||||
# + Add row IS enabled here: the form schema's `party` field doubles
|
||||
# as the routing key — the server reads the submitted `party` field,
|
||||
# finds the matching <project>/archive/<party>/ folder, and writes
|
||||
# the row inside its rsk/ subfolder. The party folder must already
|
||||
# exist (create it via the SSR view).
|
||||
|
||||
title: Project Risk Register (all parties)
|
||||
description: Every risk across all parties under archive/. Click a row to edit; + Add row uses the Package column to route the new row to the matching archive/<party>/rsk/ folder.
|
||||
|
||||
columns:
|
||||
- field: party
|
||||
- field: $party
|
||||
title: Package
|
||||
width: 7em
|
||||
- field: id
|
||||
|
|
@ -52,4 +54,4 @@ columns:
|
|||
defaults:
|
||||
sort:
|
||||
- { field: severity, dir: desc }
|
||||
- { field: party, dir: asc }
|
||||
- { field: $party, dir: asc }
|
||||
|
|
|
|||
|
|
@ -147,12 +147,16 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
|
|||
if dt := zddc.DefaultToolAt(cfg.Root, absDir); dt != "" {
|
||||
w.Header().Set("X-ZDDC-Default-Tool", dt)
|
||||
}
|
||||
// X-ZDDC-On-Plan-Review surfaces whether the cascade above this
|
||||
// path has an on_plan_review block configured. Browse uses it to
|
||||
// X-ZDDC-On-Plan-Review surfaces whether this path is eligible for
|
||||
// the Plan Review composite endpoint — true at every URL of the
|
||||
// shape /<project>/archive/<party>/received/<tracking>/, which is
|
||||
// the only shape the handler accepts. Browse uses the header to
|
||||
// show/hide the "Plan Review" right-click menu item without
|
||||
// re-implementing the cascade client-side. Boolean; absent header
|
||||
// = false.
|
||||
if zddc.OnPlanReviewAt(cfg.Root, absDir) != nil {
|
||||
// duplicating the URL test client-side. Boolean; absent header =
|
||||
// false. (Replaced the previous cascade-keyed on_plan_review check
|
||||
// when the layout reshape made archive/<party>/{reviewing,staging}/
|
||||
// the hardcoded scaffold target — see handler/planreview.go.)
|
||||
if zddc.IsPlanReviewURL(urlPath) {
|
||||
w.Header().Set("X-ZDDC-On-Plan-Review", "true")
|
||||
}
|
||||
// X-ZDDC-Canonical-Folder names the canonical project-layout slot
|
||||
|
|
|
|||
|
|
@ -694,6 +694,17 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Project-root mkdir policy: the only physical child allowed
|
||||
// directly under <project>/ is `archive` (plus _/.-prefixed
|
||||
// system names). Mkdir of any other name — including the six
|
||||
// virtual aggregator names (ssr/mdl/rsk/working/staging/reviewing)
|
||||
// — is rejected with 409, because the virtual would shadow any
|
||||
// physical folder created at the same URL.
|
||||
if rejected, why := rejectProjectRootMkdir(cfg.Root, abs); rejected {
|
||||
http.Error(w, why, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve canonical-folder casing on the way in (no side effects).
|
||||
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
|
||||
abs = r2
|
||||
|
|
@ -759,77 +770,63 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// Staging↔working mirror: when a folder created under staging/ matches
|
||||
// the ZDDC transmittal-folder grammar AND its tracking number contains
|
||||
// -SUB- or -TRN-, also create the same-named folder under working/ as
|
||||
// a drafting space for staff. The mirror is one-way and one-shot —
|
||||
// renames or deletions of either side are not propagated.
|
||||
if email != "" {
|
||||
mirrorStagingToWorking(cfg, abs, email)
|
||||
}
|
||||
// (The pre-reshape staging↔working mirror was retired: with
|
||||
// staging at archive/<party>/staging/<batch>/ and working at
|
||||
// archive/<party>/working/<email>/, the project-level pairing
|
||||
// no longer maps cleanly. Operators who want a per-batch drafting
|
||||
// space create it inside their own working/<email>/ home.)
|
||||
|
||||
w.Header().Set("X-ZDDC-Source", "fileapi:mkdir")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil)
|
||||
}
|
||||
|
||||
// mirrorStagingToWorking creates a paired drafting folder under working/
|
||||
// when newAbs is a transmittal-named folder under <project>/staging/. Best
|
||||
// effort — failures are logged but do not affect the staging mkdir result.
|
||||
// rejectProjectRootMkdir reports whether a mkdir at abs lands at
|
||||
// <project>/<name>/ where <name> is forbidden as a direct project-
|
||||
// root physical child. Under the canonical layout:
|
||||
//
|
||||
// Eligibility:
|
||||
// - newAbs's parent is exactly <project>/staging/ (case-fold)
|
||||
// - filepath.Base(newAbs) parses as a transmittal folder
|
||||
// (YYYY-MM-DD_<tracking> (<status>) - <title>)
|
||||
// - tracking contains -SUB- or -TRN- (case-fold)
|
||||
// - `archive` is the only physical project-root canonical folder
|
||||
// - `_`-/`.`-prefixed names are system-reserved and allowed
|
||||
// - the six virtual aggregator names (ssr/mdl/rsk/working/staging/
|
||||
// reviewing) are explicitly rejected — the virtual resolver
|
||||
// would shadow any physical folder created at those URLs
|
||||
// - any other name is rejected: project-root mkdir of an ad-hoc
|
||||
// name was an artefact of the pre-reshape layout where doc
|
||||
// controllers could create freeform top-level folders, but the
|
||||
// new model treats the project root as exclusively system + the
|
||||
// archive/ party-holder.
|
||||
//
|
||||
// Side effects on success:
|
||||
// - <project>/working/ created if missing, with auto-own .zddc seeded
|
||||
// (via EnsureCanonicalAncestors)
|
||||
// - <project>/working/<sameName>/ created if missing, with its own
|
||||
// auto-own .zddc (it's a child of the working/ canonical folder)
|
||||
func mirrorStagingToWorking(cfg config.Config, newAbs, email string) {
|
||||
rel, err := filepath.Rel(cfg.Root, newAbs)
|
||||
// Returns (true, reason) when the request should be 409'd. Returns
|
||||
// (false, "") when the target is at any other depth or carries an
|
||||
// allowed name.
|
||||
func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) {
|
||||
rel, err := filepath.Rel(fsRoot, abs)
|
||||
if err != nil {
|
||||
return
|
||||
return false, ""
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == "." || strings.HasPrefix(rel, "../") {
|
||||
return false, ""
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) != 3 {
|
||||
// Mirror only fires for direct children of staging/. Deeper paths
|
||||
// (staging/<name>/sub/) are user-managed.
|
||||
return
|
||||
if len(parts) != 2 {
|
||||
// Not a direct project-root child — depth-2 = <project>/<name>.
|
||||
return false, ""
|
||||
}
|
||||
if !strings.EqualFold(parts[1], "staging") {
|
||||
return
|
||||
name := parts[1]
|
||||
if name == "archive" {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
name := parts[2]
|
||||
_, tracking, _, _, ok := zddc.ParseTransmittalFolder(name)
|
||||
if !ok || !zddc.IsTrnOrSubTracking(tracking) {
|
||||
return
|
||||
if strings.HasPrefix(name, "_") || strings.HasPrefix(name, ".") {
|
||||
// System-reserved namespace; allowed.
|
||||
return false, ""
|
||||
}
|
||||
|
||||
mirrorPath := filepath.Join(cfg.Root, parts[0], "working", name)
|
||||
// Idempotent: skip if the working sibling already exists.
|
||||
if info, err := os.Stat(mirrorPath); err == nil && info.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
// EnsureCanonicalAncestors creates working/ (with its own auto-own .zddc)
|
||||
// if missing; we then MkdirAll the mirror folder itself and seed its
|
||||
// auto-own grant.
|
||||
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, mirrorPath, email, 0o755); err != nil {
|
||||
slog.Warn("mirror: ensure ancestors", "path", mirrorPath, "err", err)
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(mirrorPath, 0o755); err != nil {
|
||||
slog.Warn("mirror: mkdir", "path", mirrorPath, "err", err)
|
||||
return
|
||||
}
|
||||
if err := zddc.WriteAutoOwnZddc(mirrorPath, email); err != nil {
|
||||
slog.Warn("mirror: auto-own .zddc", "path", mirrorPath, "err", err)
|
||||
lower := strings.ToLower(name)
|
||||
switch lower {
|
||||
case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
|
||||
return true, "Conflict — " + lower + "/ is a project-level virtual aggregator and cannot be created as a physical folder. Files of this kind live under archive/<party>/" + lower + "/."
|
||||
}
|
||||
return true, "Conflict — only archive/ and system-reserved (_/. prefix) folders may be created directly under a project. Files belong inside archive/<party>/..."
|
||||
}
|
||||
|
||||
// auditFile emits a structured log line for each file API operation.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import (
|
|||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -306,15 +305,18 @@ func TestFileAPI_PostMissingOp(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestFileAPI_MkdirCreates(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
// Project-root mkdir is restricted to archive/ + system names
|
||||
// after the layout reshape; test mkdir at a depth where the
|
||||
// guard doesn't fire (under archive/<party>/incoming/).
|
||||
_, do, root := fileAPITestSetup(t, []string{"Proj/archive/Acme/incoming"}, nil)
|
||||
|
||||
rec := do(http.MethodPost, "/Incoming/newfolder/", "alice@example.com", nil, map[string]string{
|
||||
rec := do(http.MethodPost, "/Proj/archive/Acme/incoming/newfolder/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(root, "Incoming/newfolder"))
|
||||
info, err := os.Stat(filepath.Join(root, "Proj/archive/Acme/incoming/newfolder"))
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
|
|
@ -324,8 +326,8 @@ func TestFileAPI_MkdirCreates(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestFileAPI_MkdirIdempotent(t *testing.T) {
|
||||
_, do, _ := fileAPITestSetup(t, []string{"Incoming/exists"}, nil)
|
||||
rec := do(http.MethodPost, "/Incoming/exists/", "alice@example.com", nil, map[string]string{
|
||||
_, do, _ := fileAPITestSetup(t, []string{"Proj/archive/Acme/incoming/exists"}, nil)
|
||||
rec := do(http.MethodPost, "/Proj/archive/Acme/incoming/exists/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
|
|
@ -333,6 +335,41 @@ func TestFileAPI_MkdirIdempotent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestFileAPI_MkdirProjectRootGuard — direct mkdir at <project>/<name>/
|
||||
// is restricted: archive/ and system names (_/.-prefix) are allowed,
|
||||
// any other name (including the six virtual aggregator names) is
|
||||
// rejected with 409.
|
||||
func TestFileAPI_MkdirProjectRootGuard(t *testing.T) {
|
||||
_, do, _ := fileAPITestSetup(t, []string{"Proj"}, nil)
|
||||
// Reject ad-hoc name.
|
||||
rec := do(http.MethodPost, "/Proj/notes/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("want 409 for /Proj/notes/, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Reject each virtual aggregator name.
|
||||
for _, name := range []string{"ssr", "mdl", "rsk", "working", "staging", "reviewing"} {
|
||||
rec := do(http.MethodPost, "/Proj/"+name+"/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("%s: want 409, got %d: %s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// Allow archive/.
|
||||
rec = do(http.MethodPost, "/Proj/archive/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("want 201 for /Proj/archive/, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// `_`/`.`-prefixed system names are caught earlier (resolveTargetPath
|
||||
// rejects them as reserved path segments with 404 — see fileapi.go
|
||||
// resolveTargetPath); the mkdir guard would also allow them, so the
|
||||
// composite end-state is reserved + 404. Tested elsewhere.
|
||||
}
|
||||
|
||||
func TestFileAPI_IfMatchEnforced(t *testing.T) {
|
||||
_, do, _ := fileAPITestSetup(t, nil, map[string]string{
|
||||
"Incoming/x.txt": "v1",
|
||||
|
|
@ -630,145 +667,7 @@ func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- staging↔working mirror -------------------------------------------------
|
||||
|
||||
// stagingMirrorURL builds a URL-safe target path for a transmittal folder
|
||||
// name with spaces and parens, mirroring how a real client would encode it.
|
||||
func stagingMirrorURL(project, folder string) string {
|
||||
return "/" + project + "/staging/" + url.PathEscape(folder) + "/"
|
||||
}
|
||||
|
||||
func TestFileAPI_StagingMirror_TRN(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - Foundation Plans"
|
||||
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("staging mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Staging side exists with auto-own.
|
||||
stagingDir := filepath.Join(root, "Proj/staging", folder)
|
||||
if info, err := os.Stat(stagingDir); err != nil || !info.IsDir() {
|
||||
t.Fatalf("staging folder not created: err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stagingDir, ".zddc")); err != nil {
|
||||
t.Errorf("staging auto-own .zddc missing: %v", err)
|
||||
}
|
||||
|
||||
// Working mirror exists with auto-own.
|
||||
workingDir := filepath.Join(root, "Proj/working", folder)
|
||||
if info, err := os.Stat(workingDir); err != nil || !info.IsDir() {
|
||||
t.Fatalf("working mirror not created: err=%v", err)
|
||||
}
|
||||
mirrorZ, err := os.ReadFile(filepath.Join(workingDir, ".zddc"))
|
||||
if err != nil {
|
||||
t.Fatalf("working mirror auto-own .zddc missing: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(mirrorZ), "alice@example.com: rwcda") {
|
||||
t.Errorf("mirror .zddc missing creator grant: %s", mirrorZ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_StagingMirror_SUB(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
folder := "2026-07-01_vendor-EM-SUB-0017 (RSA) - Review Notes"
|
||||
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("mkdir: want 201, got %d", rec.Code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); err != nil {
|
||||
t.Errorf("SUB-tracked folder should mirror; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_StagingMirror_NonTransmittalNameSkipped(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
rec := do(http.MethodPost, "/Proj/staging/scratch/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("mkdir: want 201, got %d", rec.Code)
|
||||
}
|
||||
// staging/scratch/ exists.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj/staging/scratch")); err != nil {
|
||||
t.Fatalf("staging/scratch not created: %v", err)
|
||||
}
|
||||
// No working/ sibling — name doesn't parse as transmittal.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj/working/scratch")); !os.IsNotExist(err) {
|
||||
t.Errorf("non-transmittal name must NOT mirror; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_StagingMirror_MdlTrackingSkipped(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
folder := "2026-06-15_proj-EM-MDL-0001 (IFR) - Master Deliverables List"
|
||||
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("mkdir: want 201, got %d", rec.Code)
|
||||
}
|
||||
// MDL deliverables are tracked in archive/<party>/mdl/, not via the
|
||||
// working↔staging pairing — no mirror.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); !os.IsNotExist(err) {
|
||||
t.Errorf("-MDL- tracking must NOT mirror; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_StagingMirror_DeepPathSkipped(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
// mkdir of staging/<name>/sub/ (depth 4) — only depth-3 (immediate
|
||||
// child of staging/) qualifies for mirroring.
|
||||
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - x"
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj/staging", folder), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := do(http.MethodPost, "/Proj/staging/"+url.PathEscape(folder)+"/sub/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Fatalf("deep mkdir: got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// The transmittal folder did not get a mirror retroactively because
|
||||
// the mirror only fires on depth-3 mkdirs.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); !os.IsNotExist(err) {
|
||||
t.Errorf("deep mkdir should not retroactively mirror; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_StagingMirror_Idempotent(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
// Pre-create the working sibling with a sentinel file so we can detect
|
||||
// if the mirror code blew it away.
|
||||
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - existing"
|
||||
mirrorDir := filepath.Join(root, "Proj/working", folder)
|
||||
if err := os.MkdirAll(mirrorDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sentinel := filepath.Join(mirrorDir, "preexisting.md")
|
||||
if err := os.WriteFile(sentinel, []byte("user content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("mkdir: want 201, got %d", rec.Code)
|
||||
}
|
||||
// Sentinel still exists — mirror was idempotent (no-op when sibling
|
||||
// already present).
|
||||
if _, err := os.Stat(sentinel); err != nil {
|
||||
t.Errorf("idempotency: pre-existing content gone: %v", err)
|
||||
}
|
||||
}
|
||||
// (The pre-reshape staging↔working mirror was retired: with staging at
|
||||
// archive/<party>/staging/<batch>/ and working at archive/<party>/
|
||||
// working/<email>/, the project-level pairing no longer maps cleanly.
|
||||
// Tests for the removed behaviour have been deleted.)
|
||||
|
|
|
|||
|
|
@ -31,9 +31,10 @@ import (
|
|||
// cascade defaults; the same `c` (write-once-create) verb that
|
||||
// lets them file canonical submittals lets them establish this
|
||||
// .zddc once.
|
||||
// - ActionAdmin on reviewing_root/.zddc + staging_root/.zddc. The
|
||||
// invoker must already administer those subtrees per the cascade
|
||||
// defaults.
|
||||
// - ActionAdmin on archive/<party>/reviewing/.zddc and
|
||||
// archive/<party>/staging/.zddc. The invoker must already
|
||||
// administer those subtrees per the cascade defaults (which give
|
||||
// subtree-admin of the party folder to document_controller).
|
||||
//
|
||||
// Operation:
|
||||
//
|
||||
|
|
@ -131,23 +132,31 @@ func servePlanReview(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
|||
//
|
||||
// Exposed so accept-transmittal can chain Plan Review in the same
|
||||
// request without round-tripping through HTTP.
|
||||
//
|
||||
// Path convention is hardcoded per the layout reshape: workflow
|
||||
// folders are scaffolded under archive/<party>/{reviewing,staging}/.
|
||||
// No reviewing_root/staging_root cascade keys are consulted —
|
||||
// scaffolding always lands inside the same party folder that owns the
|
||||
// originating received/<tracking>/ submittal.
|
||||
func executePlanReview(cfg config.Config, r *http.Request, project, party, tracking string, req planReviewRequest) (*planReviewResponse, int, string) {
|
||||
receivedRel := filepath.ToSlash(filepath.Join("archive", party, "received", tracking))
|
||||
receivedAbs := filepath.Join(cfg.Root, project, filepath.FromSlash(receivedRel))
|
||||
cleanURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"
|
||||
|
||||
prCfg := zddc.OnPlanReviewAt(cfg.Root, receivedAbs)
|
||||
if prCfg == nil || prCfg.ReviewingRoot == "" || prCfg.StagingRoot == "" {
|
||||
return nil, http.StatusConflict, "Conflict — on_plan_review is not configured in the cascade for this subtree"
|
||||
}
|
||||
reviewingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.ReviewingRoot, "/")))
|
||||
stagingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.StagingRoot, "/")))
|
||||
// Hardcoded path convention. Every project has exactly one
|
||||
// reviewing/ and one staging/ slot per party at fixed offsets;
|
||||
// the composite endpoint scaffolds inside the originating party's
|
||||
// slots.
|
||||
reviewingRoot := filepath.Join(cfg.Root, project, "archive", party, "reviewing")
|
||||
stagingRoot := filepath.Join(cfg.Root, project, "archive", party, "staging")
|
||||
|
||||
// Pre-flight authorisation. No ACL exception — we use existing
|
||||
// cascade grants:
|
||||
// (a) ActionAdmin on reviewing_root and staging_root proves the
|
||||
// invoker is subtree-admin of the workflow roots and can
|
||||
// write the workflow .zddc files.
|
||||
// (a) ActionAdmin on archive/<party>/reviewing/ and
|
||||
// archive/<party>/staging/ proves the invoker is subtree-
|
||||
// admin of the workflow roots (inherited from the per-party
|
||||
// `admins: [document_controller]` in the cascade defaults)
|
||||
// and can write the workflow .zddc files.
|
||||
// (b) The invoker has `c` (write-once-create) authority on
|
||||
// received/<tracking>/. For the doc_controller this comes
|
||||
// from `worm: [document_controller]` on received/ in the
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ func TestPlanReview_Idempotent(t *testing.T) {
|
|||
}
|
||||
|
||||
// Confirm no duplicate folders snuck in.
|
||||
reviewingRoot := filepath.Join(root, "Project-1", "reviewing")
|
||||
reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
|
||||
entries, err := os.ReadDir(reviewingRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", reviewingRoot, err)
|
||||
|
|
@ -247,7 +247,7 @@ func TestPlanReview_ReceivedZddcIsWriteOnce(t *testing.T) {
|
|||
}
|
||||
|
||||
// reviewing/.zddc reflects the new review_lead.
|
||||
reviewingRoot := filepath.Join(root, "Project-1", "reviewing")
|
||||
reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
|
||||
entries, err := os.ReadDir(reviewingRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", reviewingRoot, err)
|
||||
|
|
@ -274,11 +274,12 @@ func TestPlanReview_Forbidden(t *testing.T) {
|
|||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Project-1", "reviewing")); err == nil {
|
||||
reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
|
||||
if _, err := os.Stat(reviewingRoot); err == nil {
|
||||
// reviewing/ should not have been materialised. The mkdir
|
||||
// happens AFTER the ACL check in the handler, so refusal
|
||||
// guarantees no state change.
|
||||
entries, _ := os.ReadDir(filepath.Join(root, "Project-1", "reviewing"))
|
||||
entries, _ := os.ReadDir(reviewingRoot)
|
||||
if len(entries) > 0 {
|
||||
t.Errorf("reviewing/ created despite 403: %d entries", len(entries))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1515,7 +1515,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp">v0.0.19</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.20-dev · 2026-05-20 20:09:54 · 703449a-dirty</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -4219,6 +4219,17 @@ body.is-elevated::after {
|
|||
const col = colAt(c);
|
||||
if (!row || !col) return;
|
||||
|
||||
// $-prefixed columns are system-synthesized fields (e.g. the
|
||||
// `$party` source-party qualifier on project-rollup MDL/RSK
|
||||
// views). Their value is derived from the row's canonical
|
||||
// path on read and stripped before any write — editing them
|
||||
// would have no effect on disk, so suppress entry to edit
|
||||
// mode entirely. Selection still works for keyboard
|
||||
// navigation across the cell.
|
||||
if (typeof col.field === 'string' && col.field.charAt(0) === '$') {
|
||||
return;
|
||||
}
|
||||
|
||||
const propSchema = propertySchemaFor(col);
|
||||
|
||||
// Complex-type cells (nested object, generic array, oneOf)
|
||||
|
|
@ -5170,7 +5181,17 @@ body.is-elevated::after {
|
|||
// form-mode and never produce drafts here, so drafts only
|
||||
// contain primitive / string-array values that are safe to
|
||||
// overwrite the corresponding top-level field.
|
||||
return Object.assign({}, data || {}, drafts || {});
|
||||
//
|
||||
// $-prefixed keys are system-synthesised on read (e.g. `$party`
|
||||
// injected by the server's virtual-view handler on project-
|
||||
// rollup MDL/RSK rows). They are not part of the row's stored
|
||||
// YAML and would be rejected by the schema's additionalProperties
|
||||
// rule. Strip them before sending the write.
|
||||
const merged = Object.assign({}, data || {}, drafts || {});
|
||||
for (const k of Object.keys(merged)) {
|
||||
if (k.charAt(0) === '$') delete merged[k];
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function rowFromState(rowId) {
|
||||
|
|
|
|||
|
|
@ -8,13 +8,19 @@
|
|||
// 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.
|
||||
// to sort on and the form edit pre-fills the party name. (Identity
|
||||
// of an SSR row is the party folder name, so the field is named
|
||||
// plainly rather than sigil-prefixed.)
|
||||
// - MDL / RSK rollup rows get `$party: <party>` so the rollup table
|
||||
// can show which package each row came from. The `$` sigil marks
|
||||
// the field as system-synthesised: tables tool renders it read-
|
||||
// only and the form client strips it before submit, so a user-
|
||||
// defined `party` field on a deliverable row never collides with
|
||||
// the synthetic source-party column.
|
||||
//
|
||||
// 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:
|
||||
// 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).
|
||||
|
|
@ -79,7 +85,7 @@ func ServeVirtualViewRow(w http.ResponseWriter, r *http.Request, vv zddc.Virtual
|
|||
case zddc.VirtualViewSSRRow:
|
||||
data["name"] = vv.Party
|
||||
case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow:
|
||||
data["party"] = vv.Party
|
||||
data["$party"] = vv.Party
|
||||
}
|
||||
|
||||
out, err := yaml.Marshal(data)
|
||||
|
|
|
|||
|
|
@ -33,19 +33,20 @@ acl:
|
|||
# `reset: true` on the role at that level — ancestor definitions above
|
||||
# the reset are then excluded.
|
||||
#
|
||||
# document_controller — the people who file into archive/<party>/
|
||||
# received/ and issued/ (WORM zones). They get read+write-once-
|
||||
# create there (via the worm: lists below) and read/write
|
||||
# elsewhere in a project, plus subtree-admin of working/ and
|
||||
# staging/ so they can stand up new top-level folders and manage
|
||||
# user/staging subtrees. They are NOT subtree-admin of archive/,
|
||||
# so the WORM constraint still binds them in received/issued.
|
||||
# document_controller — the people who file into
|
||||
# archive/<party>/received/ and issued/ (WORM zones). They get
|
||||
# read+write-once-create there (via the worm: lists below) and
|
||||
# read/write elsewhere in a project, plus subtree-admin of the
|
||||
# per-party working/ + staging/ + reviewing/ so they can stand up
|
||||
# and manage drafting/transmittal/review folders. They are NOT
|
||||
# subtree-admin of archive/<party>/, so the WORM constraint still
|
||||
# binds them in received/issued.
|
||||
#
|
||||
# project_team — everyone working on a project. Read-only across
|
||||
# the project. Their own working/<email>/ home and anything they
|
||||
# create under incoming/ get a creator-owned auto-own .zddc
|
||||
# (rwcda) which wins via deepest-match, so "read-only except
|
||||
# what I own" falls out of the cascade with no special rule.
|
||||
# the project. Their own archive/<party>/working/<email>/ home and
|
||||
# anything they create under incoming/ get a creator-owned auto-
|
||||
# own .zddc (rwcda) which wins via deepest-match, so "read-only
|
||||
# except what I own" falls out of the cascade with no special rule.
|
||||
roles:
|
||||
document_controller:
|
||||
members: []
|
||||
|
|
@ -89,17 +90,32 @@ available_tools: [archive, browse, landing]
|
|||
#
|
||||
# ── Canonical project structure ────────────────────────────────────────────
|
||||
#
|
||||
# Every ZDDC project lives at a top-level directory. Under it the
|
||||
# convention is four canonical folders: archive (formal record),
|
||||
# working (in-progress workspace), staging (outbound prep), reviewing
|
||||
# (Plan-Review-managed draft workspaces). Under archive/<party>/ the
|
||||
# convention is four more: mdl (deliverables list), incoming (counterparty
|
||||
# drop zone), received (immutable submittals), issued (immutable responses).
|
||||
# Every ZDDC project lives at a top-level directory. Under it
|
||||
# `archive/` is the ONLY real top-level folder; it contains a folder
|
||||
# per party. Everything party-scoped (the SSR row, MDL/RSK rollups,
|
||||
# WORM received/issued, the incoming drop zone, and the in-flight
|
||||
# lifecycle slots working/staging/reviewing) lives uniformly under
|
||||
# archive/<party>/.
|
||||
#
|
||||
# All of this is expressed via the recursive paths: schema. None of
|
||||
# the directories need to exist on disk — the cascade walker resolves
|
||||
# behaviour from this declaration, so a fresh project lands on
|
||||
# usable empty views at every well-known URL.
|
||||
# Six top-level virtuals sit beside archive/ as resolver views:
|
||||
#
|
||||
# ssr mdl rsk tables rollups across parties
|
||||
# (with a synthesized $party column)
|
||||
# working staging browse folder-nav listings of
|
||||
# reviewing parties with non-empty content in
|
||||
# the slot (in-flight filter). The
|
||||
# virtual 302-redirects to the
|
||||
# canonical archive/<party>/<slot>/.
|
||||
#
|
||||
# Mkdir at the project root is restricted to `archive` plus system
|
||||
# (_/.-prefixed) names; the six virtual aggregator names are rejected
|
||||
# because the virtual would shadow any physical folder created at
|
||||
# those URLs (see handler/fileapi.go).
|
||||
#
|
||||
# Everything below is expressed via the recursive paths: schema. None
|
||||
# of the directories need to exist on disk — the cascade walker
|
||||
# resolves behaviour from this declaration, so a fresh project lands
|
||||
# on usable empty views at every well-known URL.
|
||||
#
|
||||
# Operators override any of this by mirroring the structure in an
|
||||
# on-disk .zddc and changing what they need; on-disk values win.
|
||||
|
|
@ -119,15 +135,43 @@ paths:
|
|||
permissions:
|
||||
project_team: r
|
||||
document_controller: rw
|
||||
# Plan Review composite endpoint: the doc controller right-clicks
|
||||
# archive/<party>/received/<tracking>/ in the browse app and gets
|
||||
# a "Plan Review" item that scaffolds workflow folders under the
|
||||
# paths below. Both keys required; omitting the block disables
|
||||
# the menu item for this subtree.
|
||||
on_plan_review:
|
||||
reviewing_root: reviewing/
|
||||
staging_root: staging/
|
||||
paths:
|
||||
# ── Top-level virtual aggregators ───────────────────────────
|
||||
#
|
||||
# Six resolver views, sibling to archive/. None of these
|
||||
# materialise on disk; the server synthesises listings by
|
||||
# walking archive/*/<slot>/ at request time and (for the
|
||||
# tables rollups) rewrites file reads/writes back to canonical
|
||||
# paths inside the per-party folders. ACL on each synthetic
|
||||
# row is evaluated against the canonical archive/<party>/
|
||||
# chain, so party owners can edit their own rows and non-
|
||||
# owners see them read-only.
|
||||
ssr:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
virtual: true
|
||||
mdl:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
virtual: true
|
||||
rsk:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
virtual: true
|
||||
working:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
virtual: true
|
||||
staging:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
virtual: true
|
||||
reviewing:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
virtual: true
|
||||
|
||||
# ── Physical party root ─────────────────────────────────────
|
||||
archive:
|
||||
default_tool: archive
|
||||
# The doc controller can create party subfolders here
|
||||
|
|
@ -145,12 +189,20 @@ paths:
|
|||
# to received/issued). That lets them set up the
|
||||
# counterparty's own .zddc afterward.
|
||||
auto_own: true
|
||||
# Doc controller is subtree-admin of this party folder —
|
||||
# full manage authority over the in-flight lifecycle
|
||||
# slots (working/staging/reviewing) declared below. The
|
||||
# WORM constraint on received/issued is enforced by the
|
||||
# cascade's worm: lists, not by admin grants, so they
|
||||
# still file write-once into those slots.
|
||||
admins: [document_controller]
|
||||
# SSR record: the party folder's ssr.yaml carries this
|
||||
# party's vendor / contract / status data. Scoped by
|
||||
# filename pattern so the lock on `kind` only applies to
|
||||
# ssr.yaml — the mdl/, rsk/, received/ subfolders are
|
||||
# untouched. No filename_format because identity is the
|
||||
# party folder name, not a composed tracking number.
|
||||
# ssr.yaml — the mdl/, rsk/, received/, working/,
|
||||
# staging/, reviewing/ subfolders are untouched. No
|
||||
# filename_format because identity is the party folder
|
||||
# name, not a composed tracking number.
|
||||
records:
|
||||
"ssr.yaml":
|
||||
field_defaults:
|
||||
|
|
@ -251,74 +303,47 @@ paths:
|
|||
issued:
|
||||
default_tool: archive
|
||||
worm: [document_controller]
|
||||
working:
|
||||
default_tool: browse
|
||||
available_tools: [browse, classifier]
|
||||
# working/ auto-owns the first creator + the per-user homes
|
||||
# below.
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
# Doc controller is subtree-admin of working/ — full create
|
||||
# + manage, including taking over a fenced per-user home if a
|
||||
# user leaves. (Scoped here, not at the project root, so the
|
||||
# WORM constraint in archive/<party>/received|issued still
|
||||
# binds them.)
|
||||
admins: [document_controller]
|
||||
paths:
|
||||
"*": # per-user home dir
|
||||
default_tool: browse
|
||||
available_tools: [browse, classifier]
|
||||
auto_own: true
|
||||
# Per-user home is private by default: the generated
|
||||
# auto-own .zddc carries inherit:false so ancestor ACL
|
||||
# grants don't reach inside. The user can edit the file
|
||||
# to grant collaborators access.
|
||||
auto_own_fenced: true
|
||||
drop_target: true
|
||||
staging:
|
||||
default_tool: transmittal
|
||||
available_tools: [transmittal, classifier]
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
# Doc controller is subtree-admin of staging/ too — same
|
||||
# rationale as working/.
|
||||
admins: [document_controller]
|
||||
reviewing:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
# reviewing/ is the doc-controller's draft-workspace area. The
|
||||
# "Plan Review" composite endpoint (see on_plan_review at project
|
||||
# level) scaffolds a physical folder here for each submittal
|
||||
# under review, with a .zddc carrying received_path back to the
|
||||
# canonical submittal in received/. Subtree-admin so the doc
|
||||
# controller can author per-folder .zddc files (originator ACL,
|
||||
# planned_date).
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
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
|
||||
# ── In-flight lifecycle slots (NEW — nested per-party) ────
|
||||
#
|
||||
# working/staging/reviewing now live inside each party
|
||||
# folder instead of at the project root. The project-
|
||||
# level <project>/{working,staging,reviewing} virtuals
|
||||
# (declared above) are folder-nav views over these
|
||||
# canonical per-party slots.
|
||||
working:
|
||||
default_tool: browse
|
||||
available_tools: [browse, classifier]
|
||||
# working/ auto-owns the first creator + the per-user
|
||||
# homes below.
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
paths:
|
||||
"*": # per-user home dir, fenced
|
||||
default_tool: browse
|
||||
available_tools: [browse, classifier]
|
||||
auto_own: true
|
||||
# Per-user home is private by default: the generated
|
||||
# auto-own .zddc carries inherit:false so ancestor ACL
|
||||
# grants don't reach inside. The user can edit the file
|
||||
# to grant collaborators access.
|
||||
auto_own_fenced: true
|
||||
drop_target: true
|
||||
staging:
|
||||
default_tool: transmittal
|
||||
available_tools: [transmittal, classifier]
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
reviewing:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
# reviewing/ is the doc-controller's draft-workspace
|
||||
# area inside this party folder. The "Plan Review"
|
||||
# composite endpoint scaffolds a physical folder here
|
||||
# for each submittal under review, with a .zddc
|
||||
# carrying received_path back to the canonical
|
||||
# submittal in received/. Subtree-admin (inherited
|
||||
# from the party-level admins:) so the doc
|
||||
# controller can author per-folder .zddc files
|
||||
# (originator ACL, planned_date).
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
|
|||
|
||||
if len(parts) >= 2 {
|
||||
seg := strings.ToLower(parts[1])
|
||||
if seg == "archive" || seg == "working" || seg == "staging" {
|
||||
if seg == "archive" {
|
||||
if err := resolveAt(1, seg); err != nil {
|
||||
return target, err
|
||||
}
|
||||
|
|
@ -60,7 +60,8 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
|
|||
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
||||
seg := strings.ToLower(parts[3])
|
||||
switch seg {
|
||||
case "mdl", "incoming", "received", "issued":
|
||||
case "mdl", "rsk", "incoming", "received", "issued",
|
||||
"working", "staging", "reviewing":
|
||||
if err := resolveAt(3, seg); err != nil {
|
||||
return target, err
|
||||
}
|
||||
|
|
@ -70,26 +71,26 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
|
|||
}
|
||||
|
||||
// EnsureCanonicalAncestors walks from fsRoot down to filepath.Dir(target),
|
||||
// creating any missing canonical-folder ancestor with MkdirAll(perm). For
|
||||
// freshly-created auto-own ancestors (working/, staging/, or
|
||||
// archive/<party>/incoming/), it also writes a creator-owned .zddc using
|
||||
// principalEmail (skipped if principalEmail is empty).
|
||||
// creating any missing canonical-folder ancestor with MkdirAll(perm).
|
||||
// For freshly-created auto-own ancestors (archive/<party>/, and the per-
|
||||
// party lifecycle slots {working,staging,reviewing,incoming}), it also
|
||||
// writes a creator-owned .zddc using principalEmail (skipped if
|
||||
// principalEmail is empty).
|
||||
//
|
||||
// Returns the resolved version of target with on-disk casing substituted
|
||||
// for any canonical ancestor whose disk variant differs from the requested
|
||||
// casing — so a pre-existing Working/ is reused rather than shadowed by a
|
||||
// new working/ sibling. The basename of target is never altered.
|
||||
// casing — so a pre-existing Archive/ is reused rather than shadowed by a
|
||||
// new archive/ sibling. The basename of target is never altered.
|
||||
//
|
||||
// Canonical positions, relative to fsRoot:
|
||||
//
|
||||
// - <project>/<canonical-root> where <canonical-root> ∈
|
||||
// {archive, working, staging}
|
||||
// - <project>/archive (the only physical project-root canonical;
|
||||
// working/staging/reviewing/ssr/mdl/rsk at project root are virtual
|
||||
// aggregators with no on-disk presence — writes targeting them
|
||||
// must be rejected by the caller's project-root mkdir guard.)
|
||||
// - <project>/archive/<party>/<canonical-party> where
|
||||
// <canonical-party> ∈ {mdl, incoming, received, issued}
|
||||
//
|
||||
// "reviewing" is intentionally NOT created here — it's a purely virtual
|
||||
// route. A write that targets a path under <project>/reviewing/ returns
|
||||
// an error (callers should reject before invoking this helper).
|
||||
// <canonical-party> ∈ {mdl, rsk, incoming, received, issued,
|
||||
// working, staging, reviewing}
|
||||
//
|
||||
// fsRoot and target must be absolute filesystem paths under the same
|
||||
// volume; target may not yet exist on disk.
|
||||
|
|
@ -109,9 +110,15 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
|||
return target, nil
|
||||
}
|
||||
|
||||
// Reject writes under reviewing/ — virtual route.
|
||||
if len(parts) >= 2 && strings.EqualFold(parts[1], "reviewing") {
|
||||
return target, fmt.Errorf("reviewing/ is virtual and not writable")
|
||||
// Reject writes targeting top-level virtual aggregators —
|
||||
// <project>/{ssr,mdl,rsk,working,staging,reviewing}/... — these
|
||||
// resolve through ResolveVirtualView, not as physical paths. A
|
||||
// caller writing under them bypassed the virtual resolver.
|
||||
if len(parts) >= 2 {
|
||||
switch strings.ToLower(parts[1]) {
|
||||
case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
|
||||
return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
resolvedSegs := make([]string, len(parts))
|
||||
|
|
@ -155,21 +162,23 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
|||
// Walk depth 1 (project) → deeper levels, resolving + tracking as we go.
|
||||
// Depth 0 is the project segment; not a canonical name.
|
||||
if len(parts) >= 2 {
|
||||
// Depth 1 candidate: archive / working / staging.
|
||||
// Depth 1 candidate: archive (only physical project-root canonical).
|
||||
seg := strings.ToLower(parts[1])
|
||||
if seg == "archive" || seg == "working" || seg == "staging" {
|
||||
if seg == "archive" {
|
||||
if err := resolveAt(1, seg); err != nil {
|
||||
return target, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Depth 3 candidate (archive/<party>/<canonical-party>): mdl / incoming /
|
||||
// received / issued. Only meaningful when depth 1 is "archive".
|
||||
// Depth 3 candidate (archive/<party>/<canonical-party>): the eight
|
||||
// physical per-party slots. Only meaningful when depth 1 is
|
||||
// "archive".
|
||||
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
||||
seg := strings.ToLower(parts[3])
|
||||
switch seg {
|
||||
case "mdl", "incoming", "received", "issued":
|
||||
case "mdl", "rsk", "incoming", "received", "issued",
|
||||
"working", "staging", "reviewing":
|
||||
if err := resolveAt(3, seg); err != nil {
|
||||
return target, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import (
|
|||
|
||||
func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "Proj", "working", "alice@x.com", "notes.md")
|
||||
// Per-user homes now live under archive/<party>/working/<email>/
|
||||
// after the top-of-project reshape. The depth-3 working slot is
|
||||
// the canonical-folder position; its auto-own .zddc is unfenced
|
||||
// and the depth-4 per-user home gets the fenced one.
|
||||
target := filepath.Join(root, "Proj", "archive", "ACME", "working", "alice@x.com", "notes.md")
|
||||
|
||||
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
||||
if err != nil {
|
||||
|
|
@ -19,8 +23,10 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
|
|||
t.Errorf("resolved=%q, target=%q (no case variant exists, should be identical)", resolved, target)
|
||||
}
|
||||
|
||||
// working/ is now created with auto-own .zddc.
|
||||
autoZ := filepath.Join(root, "Proj", "working", ".zddc")
|
||||
// working/ is now created with auto-own .zddc (unfenced — party
|
||||
// admins still cascade through, only the per-user home below is
|
||||
// fenced).
|
||||
autoZ := filepath.Join(root, "Proj", "archive", "ACME", "working", ".zddc")
|
||||
data, err := os.ReadFile(autoZ)
|
||||
if err != nil {
|
||||
t.Fatalf("auto-own .zddc not written at working/: %v", err)
|
||||
|
|
@ -32,12 +38,15 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
|
|||
if !strings.Contains(body, "created_by: alice@x.com") {
|
||||
t.Errorf("created_by missing: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "inherit: false") {
|
||||
t.Errorf("party working/ .zddc should be UNFENCED so party admins still reach inside; got: %s", body)
|
||||
}
|
||||
|
||||
// alice@x.com/ subfolder gets a FENCED auto-own .zddc — private by
|
||||
// default so other users can't read alice's drafts via ancestor
|
||||
// cascade. alice can edit the file later to add collaborators.
|
||||
homeZddc := filepath.Join(root, "Proj", "working", "alice@x.com", ".zddc")
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "alice@x.com")); err != nil {
|
||||
homeZddc := filepath.Join(root, "Proj", "archive", "ACME", "working", "alice@x.com", ".zddc")
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working", "alice@x.com")); err != nil {
|
||||
t.Errorf("subfolder not created: %v", err)
|
||||
}
|
||||
homeData, err := os.ReadFile(homeZddc)
|
||||
|
|
@ -58,47 +67,52 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
|
|||
// under working/ get the fence.
|
||||
func TestEnsureCanonicalAncestors_StagingChildNotFenced(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "Proj", "staging",
|
||||
target := filepath.Join(root, "Proj", "archive", "ACME", "staging",
|
||||
"2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", "doc.pdf")
|
||||
if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
// staging/<folder>/.zddc should not exist (only the parent staging/
|
||||
// gets an auto-own; the date-named child is plain).
|
||||
childZddc := filepath.Join(root, "Proj", "staging",
|
||||
childZddc := filepath.Join(root, "Proj", "archive", "ACME", "staging",
|
||||
"2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", ".zddc")
|
||||
if _, err := os.Stat(childZddc); !os.IsNotExist(err) {
|
||||
t.Errorf("staging child should NOT have auto-own .zddc; got err=%v", err)
|
||||
}
|
||||
// And the staging/ slot itself gets the unfenced auto-own.
|
||||
stagingZddc := filepath.Join(root, "Proj", "archive", "ACME", "staging", ".zddc")
|
||||
if _, err := os.Stat(stagingZddc); err != nil {
|
||||
t.Errorf("party staging/ auto-own .zddc missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCanonicalAncestors_CaseFoldReuse(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Pre-create Working/ (PascalCase).
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "Working"), 0o755); err != nil {
|
||||
// Pre-create Archive/ (PascalCase) — case-fold reuse applies to
|
||||
// the canonical project-root slot.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "Archive", "ACME", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
target := filepath.Join(root, "Proj", "working", "foo.md")
|
||||
target := filepath.Join(root, "Proj", "archive", "ACME", "working", "foo.md")
|
||||
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
|
||||
// Resolved path uses on-disk Working/ casing.
|
||||
want := filepath.Join(root, "Proj", "Working", "foo.md")
|
||||
// Resolved path uses on-disk Archive/ casing.
|
||||
want := filepath.Join(root, "Proj", "Archive", "ACME", "working", "foo.md")
|
||||
if resolved != want {
|
||||
t.Errorf("resolved=%q, want %q", resolved, want)
|
||||
}
|
||||
|
||||
// No new working/ sibling.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); !os.IsNotExist(err) {
|
||||
// No new lowercase archive/ sibling.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive")); !os.IsNotExist(err) {
|
||||
t.Errorf("lowercase sibling should not exist; got err=%v", err)
|
||||
}
|
||||
|
||||
// Working/ already existed before our call — no auto-own .zddc was
|
||||
// retroactively written.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "Working", ".zddc")); !os.IsNotExist(err) {
|
||||
// Archive/ already existed — no auto-own .zddc was retroactively written.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "Archive", ".zddc")); !os.IsNotExist(err) {
|
||||
t.Errorf("auto-own .zddc should not be written to a pre-existing folder; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -168,30 +182,35 @@ func TestEnsureCanonicalAncestors_WormFoldersNoAutoOwn(t *testing.T) {
|
|||
|
||||
func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "Proj", "working", "anon.md")
|
||||
target := filepath.Join(root, "Proj", "archive", "ACME", "working", "anon.md")
|
||||
|
||||
_, err := EnsureCanonicalAncestors(root, target, "" /* no email */, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working")); err != nil {
|
||||
t.Errorf("working/ not created: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working", ".zddc")); !os.IsNotExist(err) {
|
||||
t.Errorf("auto-own .zddc must not be written without principalEmail; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCanonicalAncestors_RejectsReviewing(t *testing.T) {
|
||||
// Project-root virtual aggregator names are rejected — a write
|
||||
// targeting <project>/working/<...> bypasses the virtual resolver
|
||||
// and must not materialise on disk.
|
||||
func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "Proj", "reviewing", "x.md")
|
||||
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for write under reviewing/, got nil")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "reviewing")); !os.IsNotExist(err) {
|
||||
t.Errorf("reviewing/ must NOT be created on disk; got err=%v", err)
|
||||
for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} {
|
||||
target := filepath.Join(root, "Proj", slot, "x.md")
|
||||
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
||||
if err == nil {
|
||||
t.Errorf("%s: expected error for write under <project>/%s/, got nil", slot, slot)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", slot)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s: <project>/%s/ must NOT be created on disk; got err=%v", slot, slot, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,18 +76,6 @@ type Role struct {
|
|||
Reset bool `yaml:"reset,omitempty" json:"reset,omitempty"`
|
||||
}
|
||||
|
||||
// OnPlanReviewConfig is the cascade block enabling the doc-controller
|
||||
// "Plan Review" composite endpoint. ReviewingRoot and StagingRoot are
|
||||
// paths relative to the master root (e.g. "<project>/reviewing/" or
|
||||
// "archive/<project>/reviewing/"). Both must be non-empty for the
|
||||
// feature to enable; either being empty disables Plan Review for this
|
||||
// subtree (the right-click menu item hides client-side via
|
||||
// /.profile/access exposure of this config).
|
||||
type OnPlanReviewConfig struct {
|
||||
ReviewingRoot string `yaml:"reviewing_root,omitempty" json:"reviewing_root,omitempty"`
|
||||
StagingRoot string `yaml:"staging_root,omitempty" json:"staging_root,omitempty"`
|
||||
}
|
||||
|
||||
// ConvertMetadata supplies per-project template variables for the
|
||||
// server-side MD→{docx,html,pdf} conversion endpoint. The handler
|
||||
// resolves the effective set by walking the .zddc cascade leaf→root
|
||||
|
|
@ -337,12 +325,6 @@ type ZddcFile struct {
|
|||
PlannedReviewDate string `yaml:"planned_review_date,omitempty" json:"planned_review_date,omitempty"`
|
||||
PlannedResponseDate string `yaml:"planned_response_date,omitempty" json:"planned_response_date,omitempty"`
|
||||
|
||||
// OnPlanReview is the cascade-declared configuration for the
|
||||
// "Plan Review" composite endpoint. Empty (nil) means Plan Review
|
||||
// is not enabled at this subtree — the browse client hides the
|
||||
// menu item. Set in an ancestor .zddc to enable.
|
||||
OnPlanReview *OnPlanReviewConfig `yaml:"on_plan_review,omitempty" json:"on_plan_review,omitempty"`
|
||||
|
||||
// FieldCodes declares the vocabulary of "field codes" used as
|
||||
// components of tracking numbers and as constrained body fields
|
||||
// on record YAMLs (mdl rows, rsk rows, ssr rows). The map key is
|
||||
|
|
|
|||
|
|
@ -226,58 +226,45 @@ func ChildrenDeclaredAt(fsRoot, dirPath string) []string {
|
|||
|
||||
// CanonicalFolderAt returns the canonical-folder name for THIS specific
|
||||
// directory — one of "archive", "working", "staging", "reviewing",
|
||||
// "incoming", "received", "issued", "mdl" — or "" if the path is not
|
||||
// at a canonical-folder slot.
|
||||
// "incoming", "received", "issued", "mdl", "rsk" — or "" if the path
|
||||
// is not at a canonical-folder slot.
|
||||
//
|
||||
// Detection is structural against the canonical project layout declared
|
||||
// in defaults.zddc.yaml: top-level <project>/{archive,working,staging,
|
||||
// reviewing} and the second-level archive/<party>/{mdl,incoming,
|
||||
// received,issued}. Operators don't rename these slots (the cascade
|
||||
// keys them by literal name); a custom layout that does is on its own.
|
||||
// in defaults.zddc.yaml:
|
||||
//
|
||||
// - top-level <project>/archive is the only physical project-root
|
||||
// canonical slot (the working/staging/reviewing/ssr/mdl/rsk URLs
|
||||
// at project root are virtual aggregators, not on-disk folders).
|
||||
// - third-level archive/<party>/{mdl,rsk,incoming,received,issued,
|
||||
// working,staging,reviewing} are the physical per-party canonical
|
||||
// slots.
|
||||
//
|
||||
// Operators don't rename these slots (the cascade keys them by
|
||||
// literal name); a custom layout that does is on its own.
|
||||
//
|
||||
// Used by the browse SPA to scope-gate context-menu actions (Accept,
|
||||
// Stage/Unstage, Create Transmittal folder) without re-implementing the
|
||||
// cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder header.
|
||||
func CanonicalFolderAt(fsRoot, dirPath string) string {
|
||||
segs := resolvePathSegments(fsRoot, dirPath)
|
||||
// <project>/<folder>
|
||||
// <project>/<folder> — only archive/ is physical at project root.
|
||||
if len(segs) == 2 {
|
||||
switch segs[1] {
|
||||
case "archive", "working", "staging", "reviewing":
|
||||
return segs[1]
|
||||
if segs[1] == "archive" {
|
||||
return "archive"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// <project>/archive/<party>/<folder>
|
||||
if len(segs) == 4 && segs[1] == "archive" {
|
||||
switch segs[3] {
|
||||
case "incoming", "received", "issued", "mdl":
|
||||
case "incoming", "received", "issued", "mdl", "rsk",
|
||||
"working", "staging", "reviewing":
|
||||
return segs[3]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// OnPlanReviewAt returns the cascade-resolved Plan Review configuration
|
||||
// for dirPath, or nil if no level (on-disk, virtual via Paths, or
|
||||
// embedded) declares one. Walks chain.Levels from leaf toward root,
|
||||
// returning the first non-nil OnPlanReview. The block has to be present
|
||||
// somewhere in the ancestry for the "Plan Review" menu item to surface
|
||||
// in the browse client and for the composite endpoint to know where to
|
||||
// scaffold workflow folders.
|
||||
func OnPlanReviewAt(fsRoot, dirPath string) *OnPlanReviewConfig {
|
||||
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
||||
if cfg := chain.Levels[i].OnPlanReview; cfg != nil {
|
||||
return cfg
|
||||
}
|
||||
}
|
||||
return chain.Embedded.OnPlanReview
|
||||
}
|
||||
|
||||
// leafLevel returns the deepest (most-specific) ZddcFile in chain.
|
||||
// Caller's responsibility to check len(chain.Levels) > 0 — but
|
||||
// returns ZddcFile{} on empty for ergonomic chaining.
|
||||
|
|
@ -303,7 +290,7 @@ func isZeroZddcFile(zf ZddcFile) bool {
|
|||
return false
|
||||
}
|
||||
if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" ||
|
||||
zf.PlannedResponseDate != "" || zf.OnPlanReview != nil {
|
||||
zf.PlannedResponseDate != "" {
|
||||
return false
|
||||
}
|
||||
if len(zf.AvailableTools) > 0 {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ import (
|
|||
// TestDefaultToolAt_FromEmbeddedConvention — the canonical default-
|
||||
// tool rules in defaults.zddc.yaml should resolve correctly for the
|
||||
// well-known paths without any on-disk .zddc.
|
||||
//
|
||||
// Layout reshape: lifecycle slots (working/staging/reviewing) now
|
||||
// live under archive/<party>/. The project-level
|
||||
// <project>/{working,staging,reviewing} URLs are virtual folder-nav
|
||||
// aggregators (default_tool=browse).
|
||||
func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
|
|
@ -18,12 +23,20 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
|
|||
}{
|
||||
{filepath.Join(root, "Project-X", "archive"), "archive"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "tables"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), "tables"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "classifier"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "archive"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), "browse"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), "browse"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), "transmittal"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), "browse"},
|
||||
// Project-level virtual aggregators.
|
||||
{filepath.Join(root, "Project-X", "ssr"), "tables"},
|
||||
{filepath.Join(root, "Project-X", "mdl"), "tables"},
|
||||
{filepath.Join(root, "Project-X", "rsk"), "tables"},
|
||||
{filepath.Join(root, "Project-X", "working"), "browse"},
|
||||
{filepath.Join(root, "Project-X", "working", "alice@example.com"), "browse"},
|
||||
{filepath.Join(root, "Project-X", "staging"), "transmittal"},
|
||||
{filepath.Join(root, "Project-X", "staging"), "browse"},
|
||||
{filepath.Join(root, "Project-X", "reviewing"), "browse"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
|
@ -45,7 +58,7 @@ func TestDirToolAt(t *testing.T) {
|
|||
// whose default_tool (no-slash form) is something else.
|
||||
for _, p := range []string{
|
||||
filepath.Join(root, "Project-X"),
|
||||
filepath.Join(root, "Project-X", "working"),
|
||||
filepath.Join(root, "Project-X", "archive", "Acme", "working"),
|
||||
filepath.Join(root, "Project-X", "archive", "Acme", "mdl"),
|
||||
filepath.Join(root, "Project-X", "random", "deep", "folder"),
|
||||
} {
|
||||
|
|
@ -73,8 +86,14 @@ func TestDirToolAt(t *testing.T) {
|
|||
|
||||
// TestCanonicalFolderAt — structural detection of the canonical
|
||||
// project-layout slots that the browse SPA scope-gates context-menu
|
||||
// actions against. Top-level <project>/<folder> and second-level
|
||||
// <project>/archive/<party>/<folder>; everything else returns "".
|
||||
// actions against.
|
||||
//
|
||||
// After the layout reshape:
|
||||
// - <project>/archive is the only depth-2 canonical
|
||||
// - <project>/archive/<party>/<slot> covers the eight per-party
|
||||
// physical slots (incoming, received, issued, mdl, rsk, working,
|
||||
// staging, reviewing)
|
||||
// - everything else returns ""
|
||||
func TestCanonicalFolderAt(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
|
|
@ -83,16 +102,21 @@ func TestCanonicalFolderAt(t *testing.T) {
|
|||
want string
|
||||
}{
|
||||
{filepath.Join(root, "Project-X", "archive"), "archive"},
|
||||
{filepath.Join(root, "Project-X", "working"), "working"},
|
||||
{filepath.Join(root, "Project-X", "staging"), "staging"},
|
||||
{filepath.Join(root, "Project-X", "reviewing"), "reviewing"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "incoming"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "received"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "issued"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "mdl"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), "rsk"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), "working"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), "staging"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), "reviewing"},
|
||||
// Project-root virtuals are NOT canonical-folder slots.
|
||||
{filepath.Join(root, "Project-X", "working"), ""},
|
||||
{filepath.Join(root, "Project-X", "staging"), ""},
|
||||
{filepath.Join(root, "Project-X", "reviewing"), ""},
|
||||
{root, ""},
|
||||
{filepath.Join(root, "Project-X"), ""},
|
||||
{filepath.Join(root, "Project-X", "working", "alice@example.com"), ""},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), ""},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme"), ""},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming", "2026-05-15_Acme-0042 (RFI) - Foundation"), ""},
|
||||
{filepath.Join(root, "Project-X", "random", "dir"), ""},
|
||||
|
|
@ -107,7 +131,8 @@ func TestCanonicalFolderAt(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for
|
||||
// working/incoming/staging (per the convention) and false elsewhere.
|
||||
// the per-party lifecycle slots (working/staging/reviewing/incoming)
|
||||
// and false for received/issued/mdl/rsk.
|
||||
func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
|
|
@ -115,13 +140,15 @@ func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
|||
path string
|
||||
want bool
|
||||
}{
|
||||
{filepath.Join(root, "Project-X", "working"), true},
|
||||
{filepath.Join(root, "Project-X", "working", "alice@example.com"), true},
|
||||
{filepath.Join(root, "Project-X", "staging"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := AutoOwnAt(root, tc.path)
|
||||
|
|
@ -132,9 +159,9 @@ func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestVirtualAt_FromEmbeddedConvention — mdl/ is declared virtual;
|
||||
// everything else (including reviewing/, which is now Plan-Review-
|
||||
// managed with physical workflow folders) materialises on disk.
|
||||
// TestVirtualAt_FromEmbeddedConvention — mdl/rsk under a party are
|
||||
// declared virtual, and the six project-level aggregators are virtual.
|
||||
// Other canonical slots materialise on disk.
|
||||
func TestVirtualAt_FromEmbeddedConvention(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
|
|
@ -143,11 +170,19 @@ func TestVirtualAt_FromEmbeddedConvention(t *testing.T) {
|
|||
want bool
|
||||
}{
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true},
|
||||
{filepath.Join(root, "Project-X", "reviewing"), false},
|
||||
{filepath.Join(root, "Project-X", "working"), false},
|
||||
{filepath.Join(root, "Project-X", "staging"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
||||
// Project-level aggregators.
|
||||
{filepath.Join(root, "Project-X", "ssr"), true},
|
||||
{filepath.Join(root, "Project-X", "mdl"), true},
|
||||
{filepath.Join(root, "Project-X", "rsk"), true},
|
||||
{filepath.Join(root, "Project-X", "working"), true},
|
||||
{filepath.Join(root, "Project-X", "staging"), true},
|
||||
{filepath.Join(root, "Project-X", "reviewing"), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := VirtualAt(root, tc.path)
|
||||
|
|
@ -170,8 +205,11 @@ func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) {
|
|||
}{
|
||||
{filepath.Join(root, "Project-X", "archive"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
|
||||
// Project-root aggregators are also declared.
|
||||
{filepath.Join(root, "Project-X", "working"), true},
|
||||
{filepath.Join(root, "Project-X", "reviewing"), true},
|
||||
{filepath.Join(root, "Project-X", "ssr"), true},
|
||||
{filepath.Join(root, "Project-X", "junk"), false}, // not in convention
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
|
@ -183,17 +221,17 @@ func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project
|
||||
// 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).
|
||||
// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project root
|
||||
// the cascade declares archive/ plus the six top-level virtual
|
||||
// aggregator slots (ssr, mdl, rsk, working, staging, reviewing).
|
||||
func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X"))
|
||||
want := map[string]bool{
|
||||
"archive": true, "working": true, "staging": true, "reviewing": true,
|
||||
"ssr": true, "mdl": true, "rsk": true,
|
||||
"archive": true,
|
||||
"ssr": true, "mdl": true, "rsk": true,
|
||||
"working": true, "staging": true, "reviewing": true,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want)
|
||||
|
|
@ -211,19 +249,19 @@ func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) {
|
|||
func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "Special", "working"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(root, "Special", "archive", "Acme", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Operator declares that Special/working uses classifier
|
||||
// instead of the embedded-default browse.
|
||||
writeZddc(t, filepath.Join(root, "Special", "working"),
|
||||
// Operator declares that Special/archive/Acme/working uses
|
||||
// classifier instead of the embedded-default browse.
|
||||
writeZddc(t, filepath.Join(root, "Special", "archive", "Acme", "working"),
|
||||
"default_tool: classifier\n")
|
||||
|
||||
if got := DefaultToolAt(root, filepath.Join(root, "Special", "working")); got != "classifier" {
|
||||
if got := DefaultToolAt(root, filepath.Join(root, "Special", "archive", "Acme", "working")); got != "classifier" {
|
||||
t.Errorf("operator override should set default_tool=classifier, got %q", got)
|
||||
}
|
||||
// Default still applies at other projects.
|
||||
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "working")); got != "browse" {
|
||||
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "archive", "Acme", "working")); got != "browse" {
|
||||
t.Errorf("default convention should hold at unchanged paths, got %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -235,8 +273,9 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
|
|||
func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
// Deep path under working/ — not explicitly mentioned in paths:.
|
||||
deep := filepath.Join(root, "Project-X", "working", "alice@example.com", "notes", "sub", "deep")
|
||||
// Deep path under archive/<party>/working/ — not explicitly
|
||||
// mentioned in paths:.
|
||||
deep := filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes", "sub", "deep")
|
||||
if got := DefaultToolAt(root, deep); got != "browse" {
|
||||
t.Errorf("DefaultToolAt(%q) = %q, want browse (cascade propagation)",
|
||||
deep[len(root):], got)
|
||||
|
|
@ -248,7 +287,7 @@ func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) {
|
|||
func TestAutoOwnAt_DescendantCanDisable(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
deepDir := filepath.Join(root, "Project-X", "working", "alice@example.com")
|
||||
deepDir := filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com")
|
||||
if err := os.MkdirAll(deepDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -257,7 +296,7 @@ func TestAutoOwnAt_DescendantCanDisable(t *testing.T) {
|
|||
t.Errorf("AutoOwnAt(%q) = %v, want false (descendant override)", deepDir, got)
|
||||
}
|
||||
// Ancestor still has it true.
|
||||
ancestor := filepath.Join(root, "Project-X", "working")
|
||||
ancestor := filepath.Join(root, "Project-X", "archive", "Acme", "working")
|
||||
if got := AutoOwnAt(root, ancestor); got != true {
|
||||
t.Errorf("AutoOwnAt(%q) = %v, want true (ancestor untouched)", ancestor, got)
|
||||
}
|
||||
|
|
@ -275,7 +314,7 @@ func TestInheritFalse_BlocksEmbeddedDefaults(t *testing.T) {
|
|||
if IsDeclaredPath(root, filepath.Join(root, "Project-X", "archive")) {
|
||||
t.Errorf("with inherit:false at root, archive should not be a declared path")
|
||||
}
|
||||
if DefaultToolAt(root, filepath.Join(root, "Project-X", "working")) != "" {
|
||||
if DefaultToolAt(root, filepath.Join(root, "Project-X", "archive", "Acme", "working")) != "" {
|
||||
t.Errorf("with inherit:false at root, default_tool should be empty for working")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,13 @@ import (
|
|||
// - rw at the project level (read + overwrite-existing), but NOT c
|
||||
// (so it can't make arbitrary folders)
|
||||
// - rwc at archive/ (can create party subfolders)
|
||||
// - subtree-admin at working/ and staging/ (full create + manage)
|
||||
// - subtree-admin at archive/<party>/ (full create + manage; lifecycle
|
||||
// slots under the party inherit the admin grant)
|
||||
// - inside received/issued (WORM): masked to r + worm-restored c
|
||||
//
|
||||
// Layout reshape: working/staging/reviewing moved from project root
|
||||
// into archive/<party>/, so the subtree-admin scope likewise moved
|
||||
// from project-level "working/staging/" to the per-party folder.
|
||||
func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
|
|
@ -56,21 +61,30 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
|||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "rc")
|
||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "rc")
|
||||
|
||||
// Subtree-admin at working/ and staging/ (via admins: [document_controller]
|
||||
// in the embedded cascade — role-aware now).
|
||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "working"), Principal{Email: dc, Elevated: true}) {
|
||||
t.Errorf("doc controller should be subtree-admin of working/")
|
||||
// Subtree-admin at archive/<party>/ (the embedded cascade
|
||||
// declares admins: [document_controller] on the party "*" entry,
|
||||
// so working/staging/reviewing inside the party inherit it).
|
||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme"), Principal{Email: dc, Elevated: true}) {
|
||||
t.Errorf("doc controller should be subtree-admin of archive/<party>/")
|
||||
}
|
||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "staging"), Principal{Email: dc, Elevated: true}) {
|
||||
t.Errorf("doc controller should be subtree-admin of staging/")
|
||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working"), Principal{Email: dc, Elevated: true}) {
|
||||
t.Errorf("doc controller should be subtree-admin of archive/<party>/working/")
|
||||
}
|
||||
// NOT subtree-admin of archive/ (so WORM still binds them there).
|
||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "staging"), Principal{Email: dc, Elevated: true}) {
|
||||
t.Errorf("doc controller should be subtree-admin of archive/<party>/staging/")
|
||||
}
|
||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "reviewing"), Principal{Email: dc, Elevated: true}) {
|
||||
t.Errorf("doc controller should be subtree-admin of archive/<party>/reviewing/")
|
||||
}
|
||||
// NOT subtree-admin of archive/ (so WORM still binds them at the
|
||||
// received/issued slots below).
|
||||
if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), Principal{Email: dc, Elevated: true}) {
|
||||
t.Errorf("doc controller should NOT be subtree-admin of archive/")
|
||||
t.Errorf("doc controller should NOT be subtree-admin of archive/ (only of each party folder)")
|
||||
}
|
||||
// Subtree-admin reaches inside a fenced per-user working home.
|
||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "working", "alice@example.com"), Principal{Email: dc, Elevated: true}) {
|
||||
t.Errorf("doc controller (subtree-admin of working/) should reach inside a fenced user home")
|
||||
// Subtree-admin reaches inside a fenced per-user working home
|
||||
// under the party's working slot.
|
||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), Principal{Email: dc, Elevated: true}) {
|
||||
t.Errorf("doc controller (subtree-admin of party) should reach inside a fenced user home")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,9 +100,9 @@ func TestStandardRoles_ProjectTeamReadOnlyExceptOwned(t *testing.T) {
|
|||
members: ["*@example.com"]
|
||||
`)
|
||||
// Simulate the auto-own .zddc the file API would write at
|
||||
// working/alice@example.com/ (fenced via acl.inherit:false,
|
||||
// creator-owned).
|
||||
homeDir := filepath.Join(root, "Proj", "working", "alice@example.com")
|
||||
// archive/Acme/working/alice@example.com/ (fenced via
|
||||
// acl.inherit:false, creator-owned).
|
||||
homeDir := filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com")
|
||||
if err := os.MkdirAll(homeDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import (
|
|||
)
|
||||
|
||||
// Virtual `received/` window — the doc-controller's Plan Review composite
|
||||
// endpoint scaffolds physical folders under <reviewing_root> and
|
||||
// <staging_root>, each carrying a .zddc whose `received_path:` points back
|
||||
// at the canonical archive/<party>/received/<tracking>/. When a workflow
|
||||
// folder is listed, the server injects a synthetic `received/` child that
|
||||
// shows the canonical submittal's contents in context.
|
||||
// endpoint scaffolds physical folders under archive/<party>/reviewing/ and
|
||||
// archive/<party>/staging/, each carrying a .zddc whose `received_path:`
|
||||
// points back at the canonical archive/<party>/received/<tracking>/. When
|
||||
// a workflow folder is listed, the server injects a synthetic `received/`
|
||||
// child that shows the canonical submittal's contents in context.
|
||||
//
|
||||
// Three behaviours rely on this:
|
||||
//
|
||||
|
|
@ -51,7 +51,7 @@ func WorkflowReceivedPath(dirPath string) string {
|
|||
type VirtualReceivedResolution struct {
|
||||
Resolved bool
|
||||
WorkflowAbs string // absolute path of the workflow folder
|
||||
WorkflowURL string // server-relative URL of the workflow folder, slash-terminated (e.g. "/Project/reviewing/2026-05-30_X (TBD) - …/")
|
||||
WorkflowURL string // server-relative URL of the workflow folder, slash-terminated (e.g. "/Project/archive/Acme/reviewing/2026-05-30_X (TBD) - …/")
|
||||
ReceivedAbs string // absolute path of the canonical received target (or canonical+suffix when the URL drills into a file)
|
||||
ReceivedURL string // server-relative URL of the canonical received target
|
||||
SuffixURL string // URL suffix after the `/received/` segment, slash-prefixed when non-empty (e.g. "" or "Acme-0042_A (RFI) - Foundation.pdf")
|
||||
|
|
|
|||
|
|
@ -9,15 +9,36 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// Virtual project-level table views — SSR, MDL rollup, RSK rollup.
|
||||
// Virtual project-level views.
|
||||
//
|
||||
// All three are declared `virtual: true` in defaults.zddc.yaml under
|
||||
// `<project>/{ssr,mdl,rsk}`. The folder does not exist on disk: the
|
||||
// server synthesizes listings by walking archive/*/ at request time
|
||||
// and rewrites file reads/writes back to canonical paths inside the
|
||||
// per-party folders. ACL on each synthetic row is evaluated against
|
||||
// the canonical `<project>/archive/<party>/` chain, so party owners
|
||||
// can edit their own rows and non-owners see them read-only.
|
||||
// Six aggregators live at <project>/, all sibling to the only real
|
||||
// top-level directory archive/. None of them materialise on disk; the
|
||||
// server synthesises listings by walking archive/*/ at request time
|
||||
// and (for the tables rollups) rewrites file reads/writes back to
|
||||
// canonical paths inside the per-party folders.
|
||||
//
|
||||
// Two aggregation shapes:
|
||||
//
|
||||
// Row rollups (tables tool):
|
||||
// <project>/ssr one row per party folder under archive/, backed
|
||||
// by archive/<party>/ssr.yaml; synthesised key
|
||||
// `name: <party>` is the identity column.
|
||||
// <project>/mdl one row per *.yaml under archive/<party>/mdl/;
|
||||
// synthesised key `$party: <party>` is the
|
||||
// read-only source-party column. ($-prefix
|
||||
// prevents collision with user-defined fields.)
|
||||
// <project>/rsk same as mdl but for archive/<party>/rsk/.
|
||||
//
|
||||
// Folder-nav (browse tool):
|
||||
// <project>/working list of archive/<party>/working/ that have
|
||||
// non-empty content (in-flight filter). Per-
|
||||
// party click 302s to the canonical path.
|
||||
// <project>/staging same shape over archive/<party>/staging/.
|
||||
// <project>/reviewing same shape over archive/<party>/reviewing/.
|
||||
//
|
||||
// ACL on each synthetic row/folder is evaluated against the canonical
|
||||
// archive/<party>/ chain, so party owners can edit their own data and
|
||||
// non-owners see them read-only.
|
||||
//
|
||||
// URL conventions
|
||||
//
|
||||
|
|
@ -34,6 +55,10 @@ import (
|
|||
//
|
||||
// /<project>/rsk/ → analogous
|
||||
//
|
||||
// /<project>/working/ → folder-nav listing (parties with content)
|
||||
// /<project>/working/<party>/[<rest>] → 302 to /<project>/archive/<party>/working/<rest>
|
||||
// /<project>/staging/, /<project>/reviewing/ → analogous folder-nav
|
||||
//
|
||||
// Modeled on virtualreceived.go: one resolver produces canonical
|
||||
// paths; every caller (listing builder, file API rewrite, form
|
||||
// recognizer) reads its policy chain from the canonical path.
|
||||
|
|
@ -52,6 +77,13 @@ const (
|
|||
VirtualViewRSKRoot
|
||||
VirtualViewRSKSpec
|
||||
VirtualViewRSKRow
|
||||
// Folder-nav: top-level listing of parties with non-empty
|
||||
// content in the named lifecycle slot.
|
||||
VirtualViewFolderNavRoot
|
||||
// Folder-nav: a per-party URL under one of the folder-nav
|
||||
// roots. Resolves to a 302 redirect at canonical
|
||||
// /<project>/archive/<party>/<slot>/<rest>.
|
||||
VirtualViewFolderNavRedir
|
||||
)
|
||||
|
||||
// IsRowKind reports whether k targets a per-party row file (true for
|
||||
|
|
@ -77,15 +109,27 @@ func (k VirtualViewKind) IsSpecKind() bool {
|
|||
// virtual view.
|
||||
func (k VirtualViewKind) IsRootKind() bool {
|
||||
switch k {
|
||||
case VirtualViewSSRRoot, VirtualViewMDLRoot, VirtualViewRSKRoot:
|
||||
case VirtualViewSSRRoot, VirtualViewMDLRoot, VirtualViewRSKRoot,
|
||||
VirtualViewFolderNavRoot:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsFolderNavKind reports whether k is one of the folder-nav virtuals
|
||||
// (working, staging, reviewing). Folder-nav views surface a per-party
|
||||
// listing at the root and 302 redirect at every per-party URL.
|
||||
func (k VirtualViewKind) IsFolderNavKind() bool {
|
||||
switch k {
|
||||
case VirtualViewFolderNavRoot, VirtualViewFolderNavRedir:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// VirtualViewResolution captures the result of mapping a URL onto
|
||||
// one of the project-level virtual table views. All fields are
|
||||
// populated only when Resolved is true.
|
||||
// one of the project-level virtual views. All fields are populated
|
||||
// only when Resolved is true.
|
||||
type VirtualViewResolution struct {
|
||||
Resolved bool
|
||||
Kind VirtualViewKind
|
||||
|
|
@ -94,7 +138,7 @@ type VirtualViewResolution struct {
|
|||
ProjectURL string // "/<project>/"
|
||||
ProjectAbs string // <fsRoot>/<project>
|
||||
|
||||
Slot string // "ssr", "mdl", or "rsk"
|
||||
Slot string // "ssr", "mdl", "rsk", "working", "staging", "reviewing"
|
||||
SlotURL string // "/<project>/<slot>/"
|
||||
|
||||
// Populated for VirtualView*Spec kinds: "table.yaml" or "form.yaml".
|
||||
|
|
@ -107,12 +151,18 @@ type VirtualViewResolution struct {
|
|||
CanonicalURL string // /<project>/archive/<party>/...
|
||||
SchemaAbs string // SSR only — <party>/ssr.form.yaml (may not exist; falls back to embedded)
|
||||
RowFilename string // MDL/RSK rollups only — e.g. "D-001.yaml"
|
||||
|
||||
// Populated for VirtualViewFolderNavRedir. The path component
|
||||
// AFTER the party — empty for /<project>/<slot>/<party>/ itself,
|
||||
// or the URL-decoded sub-path for deeper URLs. The redirect
|
||||
// target is /<project>/archive/<party>/<slot>/<RedirRest>.
|
||||
RedirRest string
|
||||
}
|
||||
|
||||
// virtualViewRE matches /<project>/<slot>[/<rest>] where slot is one
|
||||
// of the canonical virtual view names. Capture 1 = project, capture
|
||||
// 2 = slot, capture 3 = rest (may be empty).
|
||||
var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(ssr|mdl|rsk)(?:/(.*))?$`)
|
||||
var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(ssr|mdl|rsk|working|staging|reviewing)(?:/(.*))?$`)
|
||||
|
||||
// partyNameRE matches the SSR schema's `name` pattern. Same regex
|
||||
// used at row-resolution time so URLs with invalid party tokens fail
|
||||
|
|
@ -126,9 +176,47 @@ func ValidPartyName(s string) bool {
|
|||
return partyNameRE.MatchString(s)
|
||||
}
|
||||
|
||||
// IsFolderNavSlot reports whether slot is one of the folder-nav
|
||||
// lifecycle slots (working, staging, reviewing).
|
||||
func IsFolderNavSlot(slot string) bool {
|
||||
switch slot {
|
||||
case "working", "staging", "reviewing":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// planReviewURLRE matches /<project>/archive/<party>/received/<tracking>/
|
||||
// — the only URL shape Plan Review accepts. Trailing slash optional.
|
||||
var planReviewURLRE = regexp.MustCompile(`^/[^/]+/archive/[^/]+/received/[^/]+/?$`)
|
||||
|
||||
// IsPlanReviewURL reports whether urlPath is a directory URL eligible
|
||||
// for the Plan Review composite endpoint — i.e. it points at the
|
||||
// canonical received/<tracking>/ folder under archive/<party>/. Used
|
||||
// to surface X-ZDDC-On-Plan-Review on directory responses so the
|
||||
// browse client can show/hide the right-click menu item.
|
||||
//
|
||||
// Eligibility is purely structural — no cascade lookup, no per-
|
||||
// project configuration. The handler-side authorisation check still
|
||||
// gates the actual operation.
|
||||
func IsPlanReviewURL(urlPath string) bool {
|
||||
return planReviewURLRE.MatchString(urlPath)
|
||||
}
|
||||
|
||||
// IsRowSlot reports whether slot is one of the tables-rollup slots
|
||||
// (ssr, mdl, rsk).
|
||||
func IsRowSlot(slot string) bool {
|
||||
switch slot {
|
||||
case "ssr", "mdl", "rsk":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveVirtualView inspects urlPath and returns a populated
|
||||
// resolution iff the URL targets one of the project-level virtual
|
||||
// views (ssr/, mdl/, rsk/). On a non-match, Resolved=false.
|
||||
// views (ssr/, mdl/, rsk/, working/, staging/, reviewing/).
|
||||
// Resolved=false on non-match.
|
||||
//
|
||||
// The resolver does NOT check that the project / party / row file
|
||||
// actually exist on disk — that's the caller's job (handlers use
|
||||
|
|
@ -165,18 +253,48 @@ func ResolveVirtualView(fsRoot, urlPath string) VirtualViewResolution {
|
|||
out.SlotURL = "/" + project + "/" + slot + "/"
|
||||
|
||||
if rest == "" {
|
||||
switch slot {
|
||||
case "ssr":
|
||||
out.Kind = VirtualViewSSRRoot
|
||||
case "mdl":
|
||||
out.Kind = VirtualViewMDLRoot
|
||||
case "rsk":
|
||||
out.Kind = VirtualViewRSKRoot
|
||||
if IsFolderNavSlot(slot) {
|
||||
out.Kind = VirtualViewFolderNavRoot
|
||||
} else {
|
||||
switch slot {
|
||||
case "ssr":
|
||||
out.Kind = VirtualViewSSRRoot
|
||||
case "mdl":
|
||||
out.Kind = VirtualViewMDLRoot
|
||||
case "rsk":
|
||||
out.Kind = VirtualViewRSKRoot
|
||||
}
|
||||
}
|
||||
out.Resolved = true
|
||||
return out
|
||||
}
|
||||
|
||||
// Folder-nav slots: any non-empty rest is a per-party redirect
|
||||
// target. /<project>/working/<party>[/...] → 302 to canonical
|
||||
// /<project>/archive/<party>/working[/...].
|
||||
if IsFolderNavSlot(slot) {
|
||||
// Split off the party (first segment) from the rest.
|
||||
party := rest
|
||||
var redirRest string
|
||||
if idx := strings.Index(rest, "/"); idx >= 0 {
|
||||
party = rest[:idx]
|
||||
redirRest = rest[idx+1:]
|
||||
}
|
||||
if !ValidPartyName(party) {
|
||||
return out
|
||||
}
|
||||
out.Party = party
|
||||
out.PartyArchive = filepath.Join(projectAbs, "archive", party)
|
||||
out.RedirRest = redirRest
|
||||
out.CanonicalURL = "/" + project + "/archive/" + party + "/" + slot + "/"
|
||||
if redirRest != "" {
|
||||
out.CanonicalURL += redirRest
|
||||
}
|
||||
out.Kind = VirtualViewFolderNavRedir
|
||||
out.Resolved = true
|
||||
return out
|
||||
}
|
||||
|
||||
if rest == "table.yaml" || rest == "form.yaml" {
|
||||
switch slot {
|
||||
case "ssr":
|
||||
|
|
@ -383,3 +501,52 @@ func ListRollupRows(fsRoot, projectAbs, slot string) ([]VirtualRollupRow, error)
|
|||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListPartyDirsInSlot walks <project>/archive/*/<slot>/ and returns
|
||||
// the party folder names whose slot directory exists AND has
|
||||
// non-empty content (the "in-flight" filter). slot must be one of
|
||||
// "working", "staging", "reviewing". Returns nil + nil when archive/
|
||||
// doesn't exist on disk.
|
||||
//
|
||||
// Used by the folder-nav virtuals at <project>/<slot>/ to list only
|
||||
// parties that have something to show. Parties whose archive/<party>/
|
||||
// <slot>/ is absent or contains only system files (.zddc) are
|
||||
// suppressed from the listing.
|
||||
func ListPartyDirsInSlot(fsRoot, projectAbs, slot string) ([]string, error) {
|
||||
if !IsFolderNavSlot(slot) {
|
||||
return nil, errors.New("ListPartyDirsInSlot: slot must be working/staging/reviewing")
|
||||
}
|
||||
parties, err := ListSSRParties(fsRoot, projectAbs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(parties))
|
||||
for _, party := range parties {
|
||||
slotDir := filepath.Join(projectAbs, "archive", party, slot)
|
||||
if !slotDirHasContent(slotDir) {
|
||||
continue
|
||||
}
|
||||
out = append(out, party)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// slotDirHasContent reports whether slotDir is a directory with at
|
||||
// least one entry that isn't a .-prefixed system file. Treats
|
||||
// .zddc-only directories as empty so the folder-nav listing doesn't
|
||||
// fire for parties whose lifecycle slot was scaffolded but never
|
||||
// populated with real work.
|
||||
func slotDirHasContent(slotDir string) bool {
|
||||
entries, err := os.ReadDir(slotDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,13 +121,12 @@ func TestResolveVirtualView_NonMatches(t *testing.T) {
|
|||
"/",
|
||||
"/Project",
|
||||
"/Project/",
|
||||
"/Project/working",
|
||||
"/Project/archive/Acme/mdl",
|
||||
"/Project/ssr/invalid__name__double.yaml", // double-double underscore is rejected
|
||||
"/Project/mdl/__leading.yaml", // empty party
|
||||
"/Project/mdl/party__.yaml", // empty rowBase
|
||||
"/Project/ssr/.hidden.yaml", // dotfile party name
|
||||
"/Project/ssr/0330C1.yaml/sub", // sub-path under row file
|
||||
"/Project/mdl/party__.yaml", // empty rowBase
|
||||
"/Project/ssr/.hidden.yaml", // dotfile party name
|
||||
"/Project/ssr/0330C1.yaml/sub", // sub-path under row file
|
||||
"/Project/notaslot/table.yaml",
|
||||
}
|
||||
for _, url := range cases {
|
||||
|
|
@ -138,6 +137,158 @@ func TestResolveVirtualView_NonMatches(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestResolveVirtualView_FolderNavRoot — the project-level virtual
|
||||
// folder-nav aggregators resolve to VirtualViewFolderNavRoot for the
|
||||
// bare slot URL (trailing slash optional).
|
||||
func TestResolveVirtualView_FolderNavRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cases := []struct {
|
||||
url string
|
||||
slot string
|
||||
}{
|
||||
{"/Project/working", "working"},
|
||||
{"/Project/working/", "working"},
|
||||
{"/Project/staging", "staging"},
|
||||
{"/Project/staging/", "staging"},
|
||||
{"/Project/reviewing", "reviewing"},
|
||||
{"/Project/reviewing/", "reviewing"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := ResolveVirtualView(root, tc.url)
|
||||
if !got.Resolved || got.Kind != VirtualViewFolderNavRoot {
|
||||
t.Errorf("%s: kind=%d resolved=%v; want FolderNavRoot resolved=true", tc.url, got.Kind, got.Resolved)
|
||||
}
|
||||
if got.Slot != tc.slot {
|
||||
t.Errorf("%s: Slot=%q want %q", tc.url, got.Slot, tc.slot)
|
||||
}
|
||||
if !got.Kind.IsRootKind() {
|
||||
t.Errorf("%s: IsRootKind=false", tc.url)
|
||||
}
|
||||
if !got.Kind.IsFolderNavKind() {
|
||||
t.Errorf("%s: IsFolderNavKind=false", tc.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveVirtualView_FolderNavRedir — URLs deeper than the bare
|
||||
// slot resolve to VirtualViewFolderNavRedir with Party + RedirRest
|
||||
// populated; the dispatcher 302s these to the canonical
|
||||
// archive/<party>/<slot>/<rest> path.
|
||||
func TestResolveVirtualView_FolderNavRedir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cases := []struct {
|
||||
url string
|
||||
wantParty string
|
||||
wantRedirRest string
|
||||
wantCanonical string
|
||||
}{
|
||||
{"/Project/working/Acme", "Acme", "", "/Project/archive/Acme/working/"},
|
||||
{"/Project/working/Acme/", "Acme", "", "/Project/archive/Acme/working/"},
|
||||
{"/Project/staging/Acme/2026-05-15_X (RFI) - T", "Acme", "2026-05-15_X (RFI) - T", "/Project/archive/Acme/staging/2026-05-15_X (RFI) - T"},
|
||||
// Trailing slash is stripped at resolver entry; the dispatcher
|
||||
// re-appends it before issuing the 302 to match the request shape.
|
||||
{"/Project/reviewing/Acme/T-0042/", "Acme", "T-0042", "/Project/archive/Acme/reviewing/T-0042"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := ResolveVirtualView(root, tc.url)
|
||||
if !got.Resolved || got.Kind != VirtualViewFolderNavRedir {
|
||||
t.Errorf("%s: kind=%d resolved=%v; want FolderNavRedir resolved=true", tc.url, got.Kind, got.Resolved)
|
||||
continue
|
||||
}
|
||||
if got.Party != tc.wantParty {
|
||||
t.Errorf("%s: Party=%q want %q", tc.url, got.Party, tc.wantParty)
|
||||
}
|
||||
if got.RedirRest != tc.wantRedirRest {
|
||||
t.Errorf("%s: RedirRest=%q want %q", tc.url, got.RedirRest, tc.wantRedirRest)
|
||||
}
|
||||
if got.CanonicalURL != tc.wantCanonical {
|
||||
t.Errorf("%s: CanonicalURL=%q want %q", tc.url, got.CanonicalURL, tc.wantCanonical)
|
||||
}
|
||||
if !got.Kind.IsFolderNavKind() {
|
||||
t.Errorf("%s: IsFolderNavKind=false", tc.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListPartyDirsInSlot — folder-nav listings include only parties
|
||||
// whose archive/<party>/<slot>/ directory exists AND has non-empty
|
||||
// content (the in-flight filter). Parties with an empty or absent
|
||||
// slot directory are suppressed.
|
||||
func TestListPartyDirsInSlot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
projectAbs := filepath.Join(root, "Project")
|
||||
|
||||
// Acme has working content; Beta has only a .zddc system file
|
||||
// (counts as empty); Gamma has the slot directory but it's
|
||||
// completely empty; Delta doesn't have the slot at all.
|
||||
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Acme", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "Acme", "working", "draft.md"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Beta", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "Beta", "working", ".zddc"), []byte(""), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Gamma", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Delta"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ListPartyDirsInSlot(root, projectAbs, "working")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"Acme"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("ListPartyDirsInSlot(working) = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListPartyDirsInSlot_BadSlot — only the three folder-nav slots
|
||||
// are valid.
|
||||
func TestListPartyDirsInSlot_BadSlot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, bad := range []string{"ssr", "mdl", "rsk", "received", "issued", "incoming", ""} {
|
||||
if _, err := ListPartyDirsInSlot(root, root, bad); err == nil {
|
||||
t.Errorf("expected error for slot=%q (only working/staging/reviewing valid)", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsPlanReviewURL — the eligibility test surfaces the X-ZDDC-On-
|
||||
// Plan-Review header. Matches /<project>/archive/<party>/received/
|
||||
// <tracking>/ with or without trailing slash; everything else returns
|
||||
// false.
|
||||
func TestIsPlanReviewURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{"/Project/archive/Acme/received/Acme-0042", true},
|
||||
{"/Project/archive/Acme/received/Acme-0042/", true},
|
||||
{"/Project/archive/Acme/received", false},
|
||||
{"/Project/archive/Acme/received/", false},
|
||||
{"/Project/archive/Acme/received/Acme-0042/file.pdf", false},
|
||||
{"/Project/archive/Acme/issued/Acme-0042/", false},
|
||||
{"/Project/archive/Acme", false},
|
||||
{"/Project/archive", false},
|
||||
{"/Project", false},
|
||||
{"/", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := IsPlanReviewURL(tc.url); got != tc.want {
|
||||
t.Errorf("IsPlanReviewURL(%q) = %v, want %v", tc.url, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSSRCreateURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
url string
|
||||
|
|
|
|||
|
|
@ -100,9 +100,6 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
|||
if top.PlannedResponseDate != "" {
|
||||
out.PlannedResponseDate = top.PlannedResponseDate
|
||||
}
|
||||
if top.OnPlanReview != nil {
|
||||
out.OnPlanReview = top.OnPlanReview
|
||||
}
|
||||
out.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools)
|
||||
|
||||
out.Admins = mergeStringSlice(out.Admins, top.Admins)
|
||||
|
|
|
|||
Loading…
Reference in a new issue