Compare commits
No commits in common. "360049f48248ff964fc3cf780cd004b2f8f0d4cf" and "703449adc557414a4eb0a4da4b5967f2a4abb147" have entirely different histories.
360049f482
...
703449adc5
59 changed files with 810 additions and 2239 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -287,7 +287,7 @@ The build enforces lockstep mechanically (one command bumps all 8). The rules be
|
||||||
No install script. Two paths:
|
No install script. Two paths:
|
||||||
|
|
||||||
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
|
- **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` 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".**
|
- **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".**
|
||||||
|
|
||||||
To override at any level, either:
|
To override at any level, either:
|
||||||
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
|
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
|
||||||
|
|
@ -489,18 +489,9 @@ roles:
|
||||||
members:
|
members:
|
||||||
- alice@burnsmcd.com
|
- alice@burnsmcd.com
|
||||||
- '*@acme.com'
|
- '*@acme.com'
|
||||||
observer:
|
|
||||||
members:
|
|
||||||
- auditor@regulator.gov
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The embedded cascade already grants `project_team: r` and `observer: 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. Plan-Review approval is part of the `document_controller` role by design — there is no separate `approver` role; two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides. The three standard roles' invariants are locked down in `zddc/internal/zddc/standardroles_test.go`.
|
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.
|
||||||
|
|
||||||
Pick a role per persona:
|
|
||||||
|
|
||||||
- `document_controller` — per-party records custodian; files into WORM `received/issued`, manages the `working/staging/reviewing` lifecycle, QCs the counterparty's drops in `incoming/`.
|
|
||||||
- `project_team` — day-to-day contributor. Read across the project; full control of their own `archive/<party>/working/<email>/` home via auto-own with a fenced `.zddc` (`inherit: false`).
|
|
||||||
- `observer` — pure read-only across the project. No auto-own home (the role itself has no `c` anywhere). Intended for auditors, regulators, and external read-only viewers who must not contribute content.
|
|
||||||
|
|
||||||
**Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`):
|
**Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`):
|
||||||
|
|
||||||
|
|
@ -614,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.
|
**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` (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.
|
**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.
|
||||||
|
|
||||||
**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.
|
**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,16 +679,7 @@ The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypa
|
||||||
|
|
||||||
#### Canonical folders, URL routing & the `.zddc` cascade
|
#### Canonical folders, URL routing & the `.zddc` cascade
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
**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:
|
The schema keys that drive built-in behavior:
|
||||||
|
|
||||||
|
|
@ -705,7 +696,7 @@ The schema keys that drive built-in behavior:
|
||||||
| `roles` | `{ name → { members:[], reset:bool } }` | members union across cascade; `reset:true` stops the walk |
|
| `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) |
|
| `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` 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.
|
**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.
|
||||||
|
|
||||||
**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`.
|
**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`.
|
||||||
|
|
||||||
|
|
@ -713,30 +704,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.
|
**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 three roles (all shipped empty — a fresh deployment grants nothing until an operator populates them):
|
**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).
|
||||||
|
|
||||||
- `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. Plan-Review approval is part of this role; there is no separate `approver` — two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides rather than baked-in roles.
|
|
||||||
- `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.
|
|
||||||
- `observer` — pure read-only across the project. Distinct from `project_team` in that the role itself carries no `c` anywhere, so an observer can't bring a working home into existence under auto-own. Intended for auditors, regulators, and external read-only viewers who must not contribute content.
|
|
||||||
|
|
||||||
The role invariants (verb sets at each canonical path, subtree-admin scope) are locked down in `zddc/internal/zddc/standardroles_test.go`. New roles, when added, should ship with a parallel test in that file.
|
|
||||||
|
|
||||||
#### Client-side capability gating
|
|
||||||
|
|
||||||
Three server surfaces feed the front-end's hide/disable model:
|
|
||||||
|
|
||||||
- **Per-entry `verbs` on every directory listing item** (`zddc/internal/listing/types.go`). Canonical `"rwcda"` subset granted to the calling principal at that entry's URL. For files it reflects the parent dir's chain (matches Writable's gate); for directories it reflects the subdir's OWN chain. `Writable` stays in lockstep during the transition window; new clients should read `verbs` and let `writable` wither.
|
|
||||||
- **`GET /.profile/access?path=<urlpath>`** returns the global view (Email, IsSuperAdmin, CanElevate, …) plus three path-scoped fields: `path_verbs` (verbs at the requested path under the caller's CURRENT elevation), `path_is_admin` (subtree-admin authority at that path under current elevation), and `path_can_elevate_grant` (verbs the caller WOULD hold at that path if they elevated, empty when elevation wouldn't change anything). Each tool fetches its current directory once on load to gate top-of-page affordances.
|
|
||||||
- **403 ACL-deny responses carry a JSON body** `{"error": "Forbidden", "missing_verb": "<r|w|c|d|a>"}` (`zddc/internal/handler/errors.go writeForbidden`). Other 403 conditions (no authenticated principal, existence-leak guards) keep plain-text bodies — `missing_verb` only applies to ACL denies.
|
|
||||||
|
|
||||||
Client side, `shared/cap.js` consumes all three: `zddc.cap.has(node, verb)` reads the listing's verbs string (falling back to `node.writable` for `w` on offline FS-API listings); `zddc.cap.at(path)` memo-fetches the path-scoped profile view; `zddc.cap.handleForbidden(resp, opts)` renders an error toast naming the missing verb and offers an Elevate button when `path_can_elevate_grant` covers it.
|
|
||||||
|
|
||||||
Each tool gates per the hide/disable rules:
|
|
||||||
- **Hide** admin-only actions (`a`), WORM-zone destructive items, and flow-terminal steps (Publish, advance state) when the verb is unattainable.
|
|
||||||
- **Disable + tooltip** everyday write affordances (Rename/Delete in the context menu, Save in editors, `+ Add row`, `+ New folder`, `Submit`) so the user discovers what permission is missing and can elevate if applicable.
|
|
||||||
- **Optimistic** for bulk / cross-directory operations — let the server return 403 and surface it via `cap.handleForbidden`.
|
|
||||||
|
|
||||||
Browse implements the per-entry gating (rename/delete + editor save); tables and form pre-flight their primary writes via `cap.at` + route 403s through `cap.handleForbidden`. Transmittal and classifier write through the FS Access API rather than the server, so server-side gating doesn't apply to their UI controls.
|
|
||||||
|
|
||||||
### File API (authenticated CRUD)
|
### 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".
|
- `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.
|
- `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.
|
- `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` 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".
|
- **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".
|
||||||
- `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.
|
- `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)
|
- `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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,6 @@ concat_files \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
"../shared/cap.js" \
|
|
||||||
> "$js_raw"
|
> "$js_raw"
|
||||||
|
|
||||||
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ concat_files \
|
||||||
"../shared/preview-lib.js" \
|
"../shared/preview-lib.js" \
|
||||||
"../shared/context-menu.js" \
|
"../shared/context-menu.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
"../shared/cap.js" \
|
|
||||||
"../shared/icons.js" \
|
"../shared/icons.js" \
|
||||||
"../shared/zddc-source.js" \
|
"../shared/zddc-source.js" \
|
||||||
"js/init.js" \
|
"js/init.js" \
|
||||||
|
|
|
||||||
|
|
@ -895,55 +895,16 @@
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
|
||||||
// ── Rename + Delete (the permission-gated pair) ──
|
// ── Rename + Delete (the permission-gated pair) ──
|
||||||
//
|
|
||||||
// Two gates compose: canMutate() rules out un-writable
|
|
||||||
// sources (offline FS-API without a handle, zip members,
|
|
||||||
// virtual placeholders) and — when the listing carries
|
|
||||||
// server-cascade verbs — zddc.cap.has(node, verb) applies
|
|
||||||
// the per-entry ACL. The verbs gate is server-mode only;
|
|
||||||
// file:// FS-API and plain Caddy listings have no verbs
|
|
||||||
// field, so we fall back to canMutate alone (FS-API
|
|
||||||
// enforces locally; Caddy has no PUT/DELETE either way).
|
|
||||||
// Server-side ACL still has the final say on the actual
|
|
||||||
// PUT/DELETE if a stale client tries the action.
|
|
||||||
{
|
{
|
||||||
label: 'Rename…',
|
label: 'Rename…',
|
||||||
disabled: function (c) {
|
disabled: function (c) { return !canMutate(c); },
|
||||||
if (!canMutate(c)) return true;
|
|
||||||
if (!serverMode || !window.zddc.cap) return false;
|
|
||||||
// verbs===undefined → Caddy or other non-zddc
|
|
||||||
// server, no cascade signal to gate on. verbs===""
|
|
||||||
// is zddc-server's explicit zero grant; still
|
|
||||||
// gate (disable). verbs==="rw…" → check the bit.
|
|
||||||
if (typeof c.node.verbs !== 'string') return false;
|
|
||||||
return !window.zddc.cap.has(c.node, 'w');
|
|
||||||
},
|
|
||||||
tooltip: function (c) {
|
|
||||||
if (!serverMode || !canMutate(c)) return '';
|
|
||||||
if (!window.zddc.cap) return '';
|
|
||||||
if (typeof c.node.verbs !== 'string') return '';
|
|
||||||
if (window.zddc.cap.has(c.node, 'w')) return '';
|
|
||||||
return "You don't have write access to this item.";
|
|
||||||
},
|
|
||||||
action: function (c) { renameNode(c.node); }
|
action: function (c) { renameNode(c.node); }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete…',
|
label: 'Delete…',
|
||||||
icon: '🗑',
|
icon: '🗑',
|
||||||
danger: true,
|
danger: true,
|
||||||
disabled: function (c) {
|
disabled: function (c) { return !canMutate(c); },
|
||||||
if (!canMutate(c)) return true;
|
|
||||||
if (!serverMode || !window.zddc.cap) return false;
|
|
||||||
if (typeof c.node.verbs !== 'string') return false;
|
|
||||||
return !window.zddc.cap.has(c.node, 'd');
|
|
||||||
},
|
|
||||||
tooltip: function (c) {
|
|
||||||
if (!serverMode || !canMutate(c)) return '';
|
|
||||||
if (!window.zddc.cap) return '';
|
|
||||||
if (typeof c.node.verbs !== 'string') return '';
|
|
||||||
if (window.zddc.cap.has(c.node, 'd')) return '';
|
|
||||||
return "You don't have delete access to this item.";
|
|
||||||
},
|
|
||||||
action: function (c) { deleteNode(c.node); }
|
action: function (c) { deleteNode(c.node); }
|
||||||
},
|
},
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
|
|
||||||
|
|
@ -40,26 +40,8 @@
|
||||||
// Server-computed write authority — true if the policy
|
// Server-computed write authority — true if the policy
|
||||||
// decider would allow a PUT for the calling principal.
|
// decider would allow a PUT for the calling principal.
|
||||||
// Absent / false means "save will 403"; preview editors
|
// Absent / false means "save will 403"; preview editors
|
||||||
// read this to mount in read-only mode. Superseded by
|
// read this to mount in read-only mode.
|
||||||
// verbs (below); kept in lockstep during the transition.
|
|
||||||
writable: !!e.writable,
|
writable: !!e.writable,
|
||||||
// Server-computed verb set: canonical "rwcda" subset the
|
|
||||||
// calling principal holds at this entry's URL. Per-entry
|
|
||||||
// gating in the context menu (Rename/Delete) reads this
|
|
||||||
// through zddc.cap.has(node, 'w'|'d').
|
|
||||||
//
|
|
||||||
// "rw…" — zddc-server emitted explicit grant.
|
|
||||||
// "" — zddc-server emitted explicit zero grant
|
|
||||||
// (rare; usually the entry would have been
|
|
||||||
// filtered before reaching the client).
|
|
||||||
// undefined — the server didn't emit a verbs field at
|
|
||||||
// all (Caddy or any non-zddc backend).
|
|
||||||
// cap.has and the events.js gates treat
|
|
||||||
// this as "verbs unknown" and skip the
|
|
||||||
// per-entry cascade gate; canMutate +
|
|
||||||
// whatever the server enforces on the
|
|
||||||
// actual PUT/DELETE still apply.
|
|
||||||
verbs: typeof e.verbs === 'string' ? e.verbs : undefined,
|
|
||||||
// FS-API specific (null in server mode):
|
// FS-API specific (null in server mode):
|
||||||
handle: null
|
handle: null
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -304,14 +304,11 @@
|
||||||
|
|
||||||
function canSave(node) {
|
function canSave(node) {
|
||||||
if (isZipMemberNode(node)) return false;
|
if (isZipMemberNode(node)) return false;
|
||||||
// Server-computed authority gate. The listing's verbs string
|
// Server-computed authority gate. The listing's `writable`
|
||||||
// tells us whether a PUT to this entry would be allowed —
|
// bit reflects what a PUT would do — false here means the
|
||||||
// false here means the file API would 403, so we mount in
|
// file API would 403 the save, so we mount in read-only
|
||||||
// read-only mode rather than letting the user type and lose
|
// mode rather than letting the user type and lose changes.
|
||||||
// changes. cap.has() falls back to node.writable for 'w'
|
if (node.url && window.app.state.source === 'server' && !node.writable) return false;
|
||||||
// when verbs is absent (offline FS-API listings).
|
|
||||||
if (node.url && window.app.state.source === 'server'
|
|
||||||
&& window.zddc.cap && !window.zddc.cap.has(node, 'w')) return false;
|
|
||||||
if (node.handle && typeof node.handle.createWritable === 'function') return true;
|
if (node.handle && typeof node.handle.createWritable === 'function') return true;
|
||||||
if (node.url && window.app.state.source === 'server') return true;
|
if (node.url && window.app.state.source === 'server') return true;
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -82,15 +82,10 @@
|
||||||
// user home, canonical-folder virtuals) is just a tree
|
// user home, canonical-folder virtuals) is just a tree
|
||||||
// affordance, not a writable file.
|
// affordance, not a writable file.
|
||||||
if (node.virtual && node.name !== '.zddc') return false;
|
if (node.virtual && node.name !== '.zddc') return false;
|
||||||
// Server-computed authority gate. The virtual .zddc entry
|
// Server-computed authority gate. Mirrors the markdown editor's
|
||||||
// requires the admin verb 'a' (matches fileapi.go's
|
// check — listing's `writable` bit is the same decision the
|
||||||
// ActionAdmin gate at the .zddc URL); regular YAML files
|
// file API would reach on PUT.
|
||||||
// require write 'w'. cap.has falls back to node.writable for
|
if (node.url && window.app.state.source === 'server' && !node.writable) return false;
|
||||||
// 'w' when verbs is absent (offline FS-API listings).
|
|
||||||
if (node.url && window.app.state.source === 'server' && window.zddc.cap) {
|
|
||||||
var needed = node.name === '.zddc' ? 'a' : 'w';
|
|
||||||
if (!window.zddc.cap.has(node, needed)) return false;
|
|
||||||
}
|
|
||||||
if (node.handle && typeof node.handle.createWritable === 'function') return true;
|
if (node.handle && typeof node.handle.createWritable === 'function') return true;
|
||||||
if (node.url && window.app.state.source === 'server') return true;
|
if (node.url && window.app.state.source === 'server') return true;
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,12 @@
|
||||||
// stage.js — Stage and Unstage workflow modals.
|
// stage.js — Stage and Unstage workflow modals.
|
||||||
//
|
//
|
||||||
// After the layout reshape, working/ and staging/ live INSIDE each
|
// Stage: move a file from working/<…>/ into a transmittal folder under
|
||||||
// party folder: archive/<party>/working/<email>/<file> and
|
// staging/<…>/. Modal lists existing transmittal folders in staging/
|
||||||
// archive/<party>/staging/<batch>/<file>. Stage and Unstage are now
|
// plus a "New transmittal folder…" option that prompts for a ZDDC-
|
||||||
// per-party — the destination batch is always inside the SAME
|
// conforming name and mkdirs it before the move.
|
||||||
// party's staging slot. The party context is read from the source
|
|
||||||
// file's path.
|
|
||||||
//
|
//
|
||||||
// Stage: move a file from archive/<party>/working/<…> into a
|
// Unstage: move a file from staging/<transmittal>/ back to the user's
|
||||||
// transmittal folder under archive/<party>/staging/<…>. Modal lists
|
// working/<email>/ home (overridable).
|
||||||
// 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
|
// Both reuse the existing X-ZDDC-Op: move primitive — no new composite
|
||||||
// endpoint is needed; the client just orchestrates one POST per file
|
// endpoint is needed; the client just orchestrates one POST per file
|
||||||
|
|
@ -35,37 +26,32 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scope detection: path-shape, not cascade-content ──────────────
|
// ── Scope detection: path-shape, not cascade-content ──────────────
|
||||||
// A file is stageable if its path matches
|
// A file is stageable if its containing folder lives under
|
||||||
// /<project>/archive/<party>/working/<…>. Unstageable if it
|
// /<project>/working/<…>. Unstageable if it lives under
|
||||||
// matches /<project>/archive/<party>/staging/<transmittal>/<…>.
|
// /<project>/staging/<transmittal>/<…>. Both are path-shape
|
||||||
// Both are path-shape queries — content/ACL is enforced server-
|
// queries — content/ACL is enforced server-side.
|
||||||
// side.
|
|
||||||
|
|
||||||
// projectPartySlot returns { project, party, slot, rest } when
|
function projectAndSubtree(path) {
|
||||||
// path matches /<project>/archive/<party>/<slot>/<rest…>, or
|
|
||||||
// null on non-match.
|
|
||||||
function projectPartySlot(path) {
|
|
||||||
var rel = path.replace(/^\/+|\/+$/g, '').split('/');
|
var rel = path.replace(/^\/+|\/+$/g, '').split('/');
|
||||||
if (rel.length < 4) return null;
|
if (rel.length < 2) return null;
|
||||||
if (rel[1].toLowerCase() !== 'archive') return null;
|
return { project: rel[0], subtree: rel[1], rest: rel.slice(2) };
|
||||||
return { project: rel[0], party: rel[2], slot: rel[3], rest: rel.slice(4) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStageableFile(node) {
|
function isStageableFile(node) {
|
||||||
if (!node || node.isDir || node.virtual) return false;
|
if (!node || node.isDir || node.virtual) return false;
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
if (!tree) return false;
|
if (!tree) return false;
|
||||||
var p = projectPartySlot(tree.pathFor(node));
|
var p = projectAndSubtree(tree.pathFor(node));
|
||||||
return !!(p && p.slot === 'working' && p.rest.length >= 1);
|
return !!(p && p.subtree === 'working' && p.rest.length >= 1);
|
||||||
}
|
}
|
||||||
function isUnstageableFile(node) {
|
function isUnstageableFile(node) {
|
||||||
if (!node || node.isDir || node.virtual) return false;
|
if (!node || node.isDir || node.virtual) return false;
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
if (!tree) return false;
|
if (!tree) return false;
|
||||||
var p = projectPartySlot(tree.pathFor(node));
|
var p = projectAndSubtree(tree.pathFor(node));
|
||||||
// archive/<party>/staging/<transmittal-folder>/<file> — at
|
// staging/<transmittal-folder>/<file> — at least one folder
|
||||||
// least one folder segment between staging/ and the file.
|
// segment between staging/ and the file.
|
||||||
return !!(p && p.slot === 'staging' && p.rest.length >= 2);
|
return !!(p && p.subtree === 'staging' && p.rest.length >= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Server helpers ─────────────────────────────────────────────────
|
// ── Server helpers ─────────────────────────────────────────────────
|
||||||
|
|
@ -83,9 +69,8 @@
|
||||||
return Array.isArray(data) ? data : [];
|
return Array.isArray(data) ? data : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStagingFolders(project, party) {
|
async function fetchStagingFolders(project) {
|
||||||
var entries = await listDir(
|
var entries = await listDir('/' + project + '/staging/');
|
||||||
'/' + project + '/archive/' + encodeURIComponent(party) + '/staging/');
|
|
||||||
return entries
|
return entries
|
||||||
.filter(function (e) { return e && e.isDir; })
|
.filter(function (e) { return e && e.isDir; })
|
||||||
.map(function (e) { return e.name; });
|
.map(function (e) { return e.name; });
|
||||||
|
|
@ -271,15 +256,14 @@
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
if (!tree) return;
|
if (!tree) return;
|
||||||
var srcUrl = tree.pathFor(node);
|
var srcUrl = tree.pathFor(node);
|
||||||
var info = projectPartySlot(srcUrl);
|
var info = projectAndSubtree(srcUrl);
|
||||||
if (!info || info.slot !== 'working') {
|
if (!info || info.subtree !== 'working') {
|
||||||
status('Stage applies only to files under archive/<party>/working/.', 'error');
|
status('Stage applies only to files under working/.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var stagingBase = '/' + info.project + '/archive/' +
|
var stagingBase = '/' + info.project + '/staging/';
|
||||||
encodeURIComponent(info.party) + '/staging/';
|
|
||||||
var folders;
|
var folders;
|
||||||
try { folders = await fetchStagingFolders(info.project, info.party); }
|
try { folders = await fetchStagingFolders(info.project); }
|
||||||
catch (e) {
|
catch (e) {
|
||||||
status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error');
|
status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error');
|
||||||
return;
|
return;
|
||||||
|
|
@ -306,21 +290,20 @@
|
||||||
status((e && e.message) || 'move failed', 'error');
|
status((e && e.message) || 'move failed', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/ — reload to see the move.', 'success');
|
status('Staged ' + node.name + ' → staging/' + choice.folderName + '/ — reload to see the move.', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function invokeUnstage(node) {
|
async function invokeUnstage(node) {
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
if (!tree) return;
|
if (!tree) return;
|
||||||
var srcUrl = tree.pathFor(node);
|
var srcUrl = tree.pathFor(node);
|
||||||
var info = projectPartySlot(srcUrl);
|
var info = projectAndSubtree(srcUrl);
|
||||||
if (!info || info.slot !== 'staging') {
|
if (!info || info.subtree !== 'staging') {
|
||||||
status('Unstage applies only to files under archive/<party>/staging/.', 'error');
|
status('Unstage applies only to files under staging/.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var email = await fetchSelfEmail();
|
var email = await fetchSelfEmail();
|
||||||
var defaultTarget = '/' + info.project + '/archive/' +
|
var defaultTarget = '/' + info.project + '/working/' + (email || '') + '/';
|
||||||
encodeURIComponent(info.party) + '/working/' + (email || '') + '/';
|
|
||||||
var choice;
|
var choice;
|
||||||
try {
|
try {
|
||||||
choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget });
|
choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget });
|
||||||
|
|
|
||||||
|
|
@ -49,16 +49,7 @@
|
||||||
// whether to mount read-only. Dropping the field here
|
// whether to mount read-only. Dropping the field here
|
||||||
// silently makes every node read-only — the actual root
|
// silently makes every node read-only — the actual root
|
||||||
// cause behind "I'm admin but the editor says read-only".
|
// cause behind "I'm admin but the editor says read-only".
|
||||||
writable: !!raw.writable,
|
writable: !!raw.writable
|
||||||
// Server-computed verb set (canonical "rwcda" subset).
|
|
||||||
// Per-entry permission gating reads this via
|
|
||||||
// zddc.cap.has(node, verb). Three states:
|
|
||||||
// "rw…" — zddc-server explicit grant
|
|
||||||
// "" — zddc-server explicit zero grant
|
|
||||||
// undefined — Caddy / FS-API listings (no verbs field).
|
|
||||||
// Per-entry gates skip the cascade check
|
|
||||||
// and fall back to canMutate / writable.
|
|
||||||
verbs: typeof raw.verbs === 'string' ? raw.verbs : undefined
|
|
||||||
};
|
};
|
||||||
state.nodes.set(id, node);
|
state.nodes.set(id, node);
|
||||||
return node;
|
return node;
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,6 @@ concat_files \
|
||||||
"js/excel.js" \
|
"js/excel.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
"../shared/cap.js" \
|
|
||||||
> "$js_raw"
|
> "$js_raw"
|
||||||
|
|
||||||
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ concat_files \
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
"../shared/cap.js" \
|
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"js/context.js" \
|
"js/context.js" \
|
||||||
"js/util.js" \
|
"js/util.js" \
|
||||||
|
|
|
||||||
|
|
@ -79,29 +79,6 @@
|
||||||
const submitBtn = document.getElementById('submit-btn');
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
if (submitBtn) {
|
if (submitBtn) {
|
||||||
submitBtn.addEventListener('click', app.modules.post.submit);
|
submitBtn.addEventListener('click', app.modules.post.submit);
|
||||||
// Pre-flight gate: hide Submit when the cascade denies
|
|
||||||
// create at the submission directory. Server still
|
|
||||||
// enforces on POST — this just avoids dangling an
|
|
||||||
// affordance that would 403. Submission directory is the
|
|
||||||
// parent of submitUrl; fall back to the page URL when
|
|
||||||
// submitUrl is absent (file:// / no-context mode).
|
|
||||||
if (window.zddc && window.zddc.cap && app.context && app.context.submitUrl) {
|
|
||||||
const subUrl = app.context.submitUrl;
|
|
||||||
const dir = subUrl.replace(/\/[^\/]*$/, '/') || subUrl;
|
|
||||||
window.zddc.cap.at(dir).then(function (view) {
|
|
||||||
if (!view) return;
|
|
||||||
const verbs = view.path_verbs || '';
|
|
||||||
if (verbs.indexOf('c') === -1) {
|
|
||||||
submitBtn.hidden = true;
|
|
||||||
const status = document.getElementById('form-status');
|
|
||||||
if (status) {
|
|
||||||
status.textContent = "You don't have permission to submit here.";
|
|
||||||
status.hidden = false;
|
|
||||||
status.classList.add('is-error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,6 @@
|
||||||
showStatus('Please correct the errors below.', 'error');
|
showStatus('Please correct the errors below.', 'error');
|
||||||
} else if (res.status === 403) {
|
} else if (res.status === 403) {
|
||||||
showStatus('You are not allowed to submit here.', 'error');
|
showStatus('You are not allowed to submit here.', 'error');
|
||||||
if (window.zddc && window.zddc.cap) {
|
|
||||||
window.zddc.cap.handleForbidden(res, {
|
|
||||||
context: 'Submit',
|
|
||||||
path: app.context.submitUrl
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (res.status === 409) {
|
} else if (res.status === 409) {
|
||||||
showStatus('A submission with this filename already exists.', 'error');
|
showStatus('A submission with this filename already exists.', 'error');
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ concat_files \
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
"../shared/cap.js" \
|
|
||||||
"js/landing.js" \
|
"js/landing.js" \
|
||||||
> "$js_raw"
|
> "$js_raw"
|
||||||
|
|
||||||
|
|
|
||||||
163
shared/cap.js
163
shared/cap.js
|
|
@ -1,163 +0,0 @@
|
||||||
// shared/cap.js — client-side capability helpers for permission gating.
|
|
||||||
//
|
|
||||||
// Three small helpers, exposed under window.zddc.cap, that wrap the
|
|
||||||
// server's verbs / /.profile/access?path / 403 missing_verb surface:
|
|
||||||
//
|
|
||||||
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
|
|
||||||
// /.profile/access?path=<urlpath> and
|
|
||||||
// memoises per-path for the session.
|
|
||||||
// Used by tools to gate top-of-page
|
|
||||||
// affordances (Publish, +Add row,
|
|
||||||
// +New folder) on PathVerbs.
|
|
||||||
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
|
|
||||||
// "rwcda"-subset) for the listed verb.
|
|
||||||
// Transition: falls back to
|
|
||||||
// node.writable for 'w' when verbs
|
|
||||||
// is absent, so the legacy field still
|
|
||||||
// drives gating on old listings.
|
|
||||||
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
|
|
||||||
// parses the JSON body for
|
|
||||||
// missing_verb and renders a toast.
|
|
||||||
// Offers "Elevate" when the path's
|
|
||||||
// /.profile/access?path= reports a
|
|
||||||
// path_can_elevate_grant covering the
|
|
||||||
// missing verb.
|
|
||||||
//
|
|
||||||
// Tools using this module must concat shared/cap.js AFTER shared/
|
|
||||||
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
|
||||||
if (window.zddc.cap) return;
|
|
||||||
|
|
||||||
var pathCache = new Map(); // path → AccessView (or null sentinel)
|
|
||||||
|
|
||||||
async function fetchAccess(path) {
|
|
||||||
// file:// pages have no server to fetch /.profile/access from;
|
|
||||||
// calling fetch() there logs a browser-level error before our
|
|
||||||
// catch even runs. Short-circuit so offline tools (browse on
|
|
||||||
// a picked folder, form opened from a file URL) silently
|
|
||||||
// degrade to "no path-scoped info, fall back to existing
|
|
||||||
// gating signals".
|
|
||||||
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var url = '/.profile/access';
|
|
||||||
if (path) url += '?path=' + encodeURIComponent(path);
|
|
||||||
var resp = await fetch(url, {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
cache: 'no-cache'
|
|
||||||
});
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
return await resp.json();
|
|
||||||
} catch (_e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// at(path) — fetch path-scoped access view, memoised per path
|
|
||||||
// within the page session. Cache is page-scoped: any elevation
|
|
||||||
// toggle forces a hard reload (see shared/elevation.js), which
|
|
||||||
// resets the cache so stale-after-elevation isn't a concern. Pass
|
|
||||||
// null/undefined for the global view (no ?path=).
|
|
||||||
async function at(path) {
|
|
||||||
var key = path || '';
|
|
||||||
if (pathCache.has(key)) return pathCache.get(key);
|
|
||||||
var view = await fetchAccess(path);
|
|
||||||
pathCache.set(key, view);
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
// has(node, verb) — check a per-entry verbs string for a single
|
|
||||||
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
|
|
||||||
// Transition shim: when node.verbs is absent, fall back to
|
|
||||||
// node.writable for 'w' so the legacy field keeps editor save
|
|
||||||
// buttons working on old listings — drop this fallback once every
|
|
||||||
// tool's loader sets node.verbs unconditionally.
|
|
||||||
function has(node, verb) {
|
|
||||||
if (!node) return false;
|
|
||||||
if (typeof node.verbs === 'string') {
|
|
||||||
return node.verbs.indexOf(verb) !== -1;
|
|
||||||
}
|
|
||||||
if (verb === 'w' && typeof node.writable === 'boolean') {
|
|
||||||
return node.writable;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
|
|
||||||
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
|
|
||||||
var VERB_LABELS = {
|
|
||||||
r: 'read',
|
|
||||||
w: 'write',
|
|
||||||
c: 'create',
|
|
||||||
d: 'delete',
|
|
||||||
a: 'edit access rules'
|
|
||||||
};
|
|
||||||
|
|
||||||
// handleForbidden(resp, opts) — render a 403 toast naming the
|
|
||||||
// missing verb. opts.path (optional) is the URL the failed request
|
|
||||||
// hit; when provided, the helper consults /.profile/access?path= to
|
|
||||||
// decide whether to offer an Elevate action. opts.context is an
|
|
||||||
// optional string prefix shown before the verb message ("Save",
|
|
||||||
// "Delete", etc.) — purely cosmetic.
|
|
||||||
//
|
|
||||||
// Best-effort: when the body isn't JSON or missing_verb is
|
|
||||||
// absent, falls back to a plain "Forbidden" toast. Returns the
|
|
||||||
// Promise so callers can await before chaining.
|
|
||||||
async function handleForbidden(resp, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
var missing = '';
|
|
||||||
try {
|
|
||||||
var body = await resp.clone().json();
|
|
||||||
if (body && typeof body.missing_verb === 'string') {
|
|
||||||
missing = body.missing_verb;
|
|
||||||
}
|
|
||||||
} catch (_e) { /* non-JSON body */ }
|
|
||||||
|
|
||||||
var prefix = opts.context ? (opts.context + ': ') : '';
|
|
||||||
var verbLabel = VERB_LABELS[missing] || missing || '';
|
|
||||||
var msg;
|
|
||||||
if (verbLabel) {
|
|
||||||
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
|
|
||||||
} else {
|
|
||||||
msg = prefix + 'Forbidden.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional elevate offer: only when the caller supplied a
|
|
||||||
// path AND the path-scoped access view reports an elevation
|
|
||||||
// grant covering the missing verb. Render as a clickable
|
|
||||||
// action appended to the toast message; clicking sets the
|
|
||||||
// elevation cookie and reloads, matching the header toggle.
|
|
||||||
var canOffer = false;
|
|
||||||
if (opts.path && missing) {
|
|
||||||
var view = await at(opts.path);
|
|
||||||
if (view && typeof view.path_can_elevate_grant === 'string'
|
|
||||||
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
|
|
||||||
canOffer = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var toastFn = (window.zddc && window.zddc.toast) || function () {};
|
|
||||||
var el = toastFn(msg, 'error', { durationMs: 8000 });
|
|
||||||
if (canOffer && el && el.appendChild) {
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'zddc-toast__action';
|
|
||||||
btn.textContent = 'Elevate';
|
|
||||||
btn.addEventListener('click', function (ev) {
|
|
||||||
ev.stopPropagation(); // don't dismiss the toast
|
|
||||||
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
|
|
||||||
window.zddc.elevation.setElevated(true);
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
el.appendChild(btn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
|
||||||
})();
|
|
||||||
|
|
@ -9,11 +9,8 @@
|
||||||
//
|
//
|
||||||
// `items` is an array (or a function returning an array, evaluated
|
// `items` is an array (or a function returning an array, evaluated
|
||||||
// against `context` at open-time). Each entry is one of:
|
// against `context` at open-time). Each entry is one of:
|
||||||
// { label, action, icon?, accel?, disabled?, visible?, danger?, tooltip? }
|
// { label, action, icon?, accel?, disabled?, visible?, danger? }
|
||||||
// — a normal menu item; `action(ctx)` fires on click/Enter.
|
// — a normal menu item; `action(ctx)` fires on click/Enter.
|
||||||
// `tooltip` (string or fn(ctx)) sets the row's title attribute —
|
|
||||||
// useful for explaining WHY a disabled item is unavailable
|
|
||||||
// ("You don't have write access here", etc.).
|
|
||||||
// { label, checked, action, ... }
|
// { label, checked, action, ... }
|
||||||
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
|
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
|
||||||
// a ✓ in the gutter when truthy.
|
// a ✓ in the gutter when truthy.
|
||||||
|
|
@ -24,10 +21,10 @@
|
||||||
// are collapsed automatically so callers can build items
|
// are collapsed automatically so callers can build items
|
||||||
// conditionally without managing dividers.
|
// conditionally without managing dividers.
|
||||||
//
|
//
|
||||||
// Any of `label`, `checked`, `visible`, `disabled`, `tooltip`, and
|
// Any of `label`, `checked`, `visible`, `disabled`, and `items` may
|
||||||
// `items` may be a function — each is invoked with the context object
|
// be a function — each is invoked with the context object so callers
|
||||||
// so callers can render fully context-aware menus from a single
|
// can render fully context-aware menus from a single declarative
|
||||||
// declarative config.
|
// config.
|
||||||
//
|
//
|
||||||
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
|
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
|
||||||
// submenu, ArrowLeft / Escape backs up one level (or closes if
|
// submenu, ArrowLeft / Escape backs up one level (or closes if
|
||||||
|
|
@ -149,10 +146,6 @@
|
||||||
row.classList.add('is-disabled');
|
row.classList.add('is-disabled');
|
||||||
row.setAttribute('aria-disabled', 'true');
|
row.setAttribute('aria-disabled', 'true');
|
||||||
}
|
}
|
||||||
if ('tooltip' in item) {
|
|
||||||
var tip = resolve(item.tooltip, ctx);
|
|
||||||
if (tip) row.title = String(tip);
|
|
||||||
}
|
|
||||||
row.setAttribute('role',
|
row.setAttribute('role',
|
||||||
hasSub ? 'menuitem'
|
hasSub ? 'menuitem'
|
||||||
: (isToggle ? 'menuitemcheckbox' : 'menuitem'));
|
: (isToggle ? 'menuitemcheckbox' : 'menuitem'));
|
||||||
|
|
|
||||||
|
|
@ -38,22 +38,3 @@
|
||||||
from { transform: translateX(0); opacity: 1; }
|
from { transform: translateX(0); opacity: 1; }
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline action button appended to a toast by zddc.cap.handleForbidden
|
|
||||||
when an Elevate path is offered. Stops click propagation on its own
|
|
||||||
so clicking the button doesn't also dismiss the toast. */
|
|
||||||
.zddc-toast__action {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 0.75rem;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
background: var(--accent, var(--text));
|
|
||||||
color: var(--bg);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.zddc-toast__action:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ concat_files \
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
"../shared/cap.js" \
|
|
||||||
"../shared/context-menu.js" \
|
"../shared/context-menu.js" \
|
||||||
"js/mode.js" \
|
"js/mode.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
|
|
|
||||||
|
|
@ -218,17 +218,6 @@
|
||||||
const col = colAt(c);
|
const col = colAt(c);
|
||||||
if (!row || !col) return;
|
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);
|
const propSchema = propertySchemaFor(col);
|
||||||
|
|
||||||
// Complex-type cells (nested object, generic array, oneOf)
|
// Complex-type cells (nested object, generic array, oneOf)
|
||||||
|
|
|
||||||
|
|
@ -125,33 +125,6 @@
|
||||||
addRowBtn.addEventListener('keydown', function (ev) {
|
addRowBtn.addEventListener('keydown', function (ev) {
|
||||||
if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev);
|
if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Permission gate: fetch the path-scoped verbs for the
|
|
||||||
// current directory and disable + Add row when the
|
|
||||||
// cascade denies create. Async — the button shows up
|
|
||||||
// optimistically and disables a tick later if the
|
|
||||||
// server says no, which is the same race window every
|
|
||||||
// path-scoped fetch has. Server still gates the POST,
|
|
||||||
// so the worst case is a 403 toast on click.
|
|
||||||
if (window.zddc && window.zddc.cap) {
|
|
||||||
window.zddc.cap.at(location.pathname).then(function (view) {
|
|
||||||
if (!view) return;
|
|
||||||
var verbs = view.path_verbs || '';
|
|
||||||
if (verbs.indexOf('c') === -1) {
|
|
||||||
addRowBtn.classList.add('is-disabled');
|
|
||||||
addRowBtn.setAttribute('aria-disabled', 'true');
|
|
||||||
addRowBtn.title = "You don't have create access in this folder.";
|
|
||||||
// Swallow clicks so the no-op feedback is the
|
|
||||||
// tooltip, not a 403 toast on submission.
|
|
||||||
addRowBtn.addEventListener('click', function (ev) {
|
|
||||||
if (addRowBtn.classList.contains('is-disabled')) {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,17 +57,7 @@
|
||||||
// form-mode and never produce drafts here, so drafts only
|
// form-mode and never produce drafts here, so drafts only
|
||||||
// contain primitive / string-array values that are safe to
|
// contain primitive / string-array values that are safe to
|
||||||
// overwrite the corresponding top-level field.
|
// 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) {
|
function rowFromState(rowId) {
|
||||||
|
|
@ -316,17 +306,6 @@
|
||||||
return { status: 'invalid', errors: errs };
|
return { status: 'invalid', errors: errs };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.status === 403) {
|
|
||||||
setRowState(rowId, 'errored');
|
|
||||||
if (window.zddc && window.zddc.cap) {
|
|
||||||
window.zddc.cap.handleForbidden(resp, {
|
|
||||||
context: 'Save row',
|
|
||||||
path: location.pathname
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { status: 'forbidden' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other status — generic error.
|
// Other status — generic error.
|
||||||
console.warn('[tables] save returned', resp.status);
|
console.warn('[tables] save returned', resp.status);
|
||||||
setRowState(rowId, 'errored');
|
setRowState(rowId, 'errored');
|
||||||
|
|
@ -406,17 +385,6 @@
|
||||||
return { status: 'invalid', errors: errs };
|
return { status: 'invalid', errors: errs };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.status === 403) {
|
|
||||||
setRowState(rowId, 'errored');
|
|
||||||
if (window.zddc && window.zddc.cap) {
|
|
||||||
window.zddc.cap.handleForbidden(resp, {
|
|
||||||
context: 'Add row',
|
|
||||||
path: location.pathname
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { status: 'forbidden' };
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn('[tables] createRow returned', resp.status);
|
console.warn('[tables] createRow returned', resp.status);
|
||||||
setRowState(rowId, 'errored');
|
setRowState(rowId, 'errored');
|
||||||
return { status: 'http-error', code: resp.status };
|
return { status: 'http-error', code: resp.status };
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ test.describe('shared/logo.js', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wraps with href=/<project> when inside a project subtree', async ({ page }) => {
|
test('wraps with href=/<project> when inside a project subtree', async ({ page }) => {
|
||||||
await page.goto(`${baseUrl}/Project-1/archive/Acme/working/casey/notes.md`, { waitUntil: 'load' });
|
await page.goto(`${baseUrl}/Project-1/working/casey/notes.md`, { waitUntil: 'load' });
|
||||||
const got = await page.evaluate(() => {
|
const got = await page.evaluate(() => {
|
||||||
const a = document.querySelector('.app-header__logo-link');
|
const a = document.querySelector('.app-header__logo-link');
|
||||||
return a && a.getAttribute('href');
|
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 }) => {
|
test('the wrapper carries an aria-label matching its target', async ({ page }) => {
|
||||||
await page.goto(`${baseUrl}/Project-1/archive/Acme/staging/`, { waitUntil: 'load' });
|
await page.goto(`${baseUrl}/Project-1/staging/`, { waitUntil: 'load' });
|
||||||
const probe = await page.evaluate(() => {
|
const probe = await page.evaluate(() => {
|
||||||
const a = document.querySelector('.app-header__logo-link');
|
const a = document.querySelector('.app-header__logo-link');
|
||||||
return a && {
|
return a && {
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,6 @@ concat_files \
|
||||||
"js/focus.js" \
|
"js/focus.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
"../shared/cap.js" \
|
|
||||||
"js/main.js" \
|
"js/main.js" \
|
||||||
> "$js_raw"
|
> "$js_raw"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1088,31 +1088,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
return
|
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
|
// File doesn't exist at this path. Before falling through to
|
||||||
// app-HTML routing or 404, check the two virtual-file-extension
|
// app-HTML routing or 404, check the two virtual-file-extension
|
||||||
// shapes that ZDDC exposes through the listing convention:
|
// shapes that ZDDC exposes through the listing convention:
|
||||||
|
|
@ -1165,11 +1140,10 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// (Top-level <project>/{working,staging,reviewing} URLs
|
// reviewing/ is no longer a virtual aggregator — it's a normal
|
||||||
// resolve as folder-nav virtuals — the per-party redirect
|
// directory under each project, populated by the Plan Review
|
||||||
// is handled above; the bare top-level URL falls through
|
// composite endpoint with physical workflow folders. Falls
|
||||||
// to ServeDirectory, where ListDirectory synthesises the
|
// through to the canonical-folder block below.
|
||||||
// folder-nav listing from ListPartyDirsInSlot.)
|
|
||||||
//
|
//
|
||||||
// Virtual received/ window. <workflow>/received/[...] is a
|
// Virtual received/ window. <workflow>/received/[...] is a
|
||||||
// synthetic view onto the canonical received/<tracking>/
|
// synthetic view onto the canonical received/<tracking>/
|
||||||
|
|
|
||||||
|
|
@ -201,18 +201,17 @@ func TestDispatchAppsResolution(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folder availability rules: classifier should NOT be served at root
|
// Folder availability rules: classifier should NOT be served at root
|
||||||
// (root has no per-party working/staging/incoming ancestor), but
|
// (root has no Incoming/Working/Staging ancestor), but SHOULD work in
|
||||||
// SHOULD work at /Project-A/archive/<party>/working/ where the per-
|
// /Project-A/Working/.
|
||||||
// party cascade declares classifier available.
|
|
||||||
rec5 := httptest.NewRecorder()
|
rec5 := httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, appsSrv, nil, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil))
|
dispatch(cfg, idx, ring, appsSrv, nil, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil))
|
||||||
if rec5.Code != http.StatusNotFound {
|
if rec5.Code != http.StatusNotFound {
|
||||||
t.Errorf("/classifier.html at root: status=%d, want 404 (not in per-party working/staging/incoming)", rec5.Code)
|
t.Errorf("/classifier.html at root: status=%d, want 404 (not in Incoming/Working/Staging)", rec5.Code)
|
||||||
}
|
}
|
||||||
rec6 := httptest.NewRecorder()
|
rec6 := httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/archive/Acme/Working/classifier.html", nil))
|
dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/Working/classifier.html", nil))
|
||||||
if rec6.Code != http.StatusOK {
|
if rec6.Code != http.StatusOK {
|
||||||
t.Errorf("/Project-A/archive/Acme/Working/classifier.html: status=%d, want 200", rec6.Code)
|
t.Errorf("/Project-A/Working/classifier.html: status=%d, want 200", rec6.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -618,18 +617,21 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// No-trailing-slash form on a canonical folder → default app.
|
// No-trailing-slash form on a canonical folder → default app
|
||||||
// Under the reshape, the project-root staging/reviewing/working
|
// (browse for working/+reviewing/, transmittal for staging/,
|
||||||
// URLs are folder-nav virtuals served by browse (the per-party
|
// archive for archive/). Mirror of the existing "no-slash →
|
||||||
// transmittal default lives at archive/<party>/staging/). archive/
|
// default app" behavior at the IsDir branch, extended to cover
|
||||||
// is still the archive tool.
|
// the case where the folder doesn't exist on disk yet.
|
||||||
noSlashDefaultApp := []struct {
|
noSlashDefaultApp := []struct {
|
||||||
stage string
|
stage string
|
||||||
expect string // substring that should appear in the response body
|
expect string // substring that should appear in the response body
|
||||||
}{
|
}{
|
||||||
{"working", "ZDDC Browse"},
|
{"working", "ZDDC Browse"},
|
||||||
{"staging", "ZDDC Browse"},
|
{"staging", "ZDDC Transmittal"},
|
||||||
{"archive", "ZDDC Archive"},
|
{"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"},
|
{"reviewing", "ZDDC Browse"},
|
||||||
}
|
}
|
||||||
for _, tc := range noSlashDefaultApp {
|
for _, tc := range noSlashDefaultApp {
|
||||||
|
|
|
||||||
|
|
@ -37,36 +37,23 @@ func AppAvailableAt(root, requestDir, app string) bool {
|
||||||
// which app to serve at a directory URL with no trailing slash —
|
// which app to serve at a directory URL with no trailing slash —
|
||||||
// trailing-slash URLs serve the browse app for any directory.
|
// trailing-slash URLs serve the browse app for any directory.
|
||||||
//
|
//
|
||||||
// Rules (case-insensitive on canonical folder names), under the
|
// Rules (case-insensitive on canonical folder names):
|
||||||
// May 2026 reshape:
|
|
||||||
//
|
//
|
||||||
// - <project>/archive/<party>/{mdl,rsk}/... → "tables"
|
// - <project>/archive/<party>/mdl/... → "tables"
|
||||||
// - <project>/archive/<party>/staging/... → "transmittal"
|
// - <project>/archive/ → "archive"
|
||||||
// - <project>/archive/<party>/{working,reviewing}/...
|
// - <project>/archive/<party>/... → "archive"
|
||||||
// → "browse" (hosts the
|
// - <project>/staging/... → "transmittal"
|
||||||
// markdown editor plugin)
|
// - <project>/working/... → "browse" (hosts the
|
||||||
// - <project>/archive/<party>/incoming/... → "classifier"
|
// markdown editor plugin)
|
||||||
// - <project>/archive/<party>/{received,issued}/...
|
// - <project>/reviewing/... → "browse" (operates on the
|
||||||
// → "archive"
|
// virtual aggregator listing)
|
||||||
// - <project>/archive/ → "archive"
|
// - any other directory → "" (no default)
|
||||||
// - <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,rsk} rule wins over the broader archive rule because the
|
// The mdl rule wins over the broader archive rule because the table
|
||||||
// table editor is a more specific surface for browsing planned
|
// editor is a more specific surface for browsing planned deliverables
|
||||||
// deliverables than the archive index. Note: the dir at
|
// than the archive index. Note: the dir at archive/<party>/mdl/
|
||||||
// archive/<party>/mdl/ itself IS the table — its table.yaml +
|
// itself IS the table — its table.yaml + form.yaml + row YAMLs all
|
||||||
// form.yaml + row YAMLs all live there together (self-contained
|
// live there together (self-contained directory).
|
||||||
// directory).
|
|
||||||
//
|
//
|
||||||
// requestDir and root are absolute filesystem paths; requestDir must
|
// requestDir and root are absolute filesystem paths; requestDir must
|
||||||
// be under root (otherwise "" is returned).
|
// be under root (otherwise "" is returned).
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,13 @@ func TestAppAvailableAt(t *testing.T) {
|
||||||
{root, "landing", true},
|
{root, "landing", true},
|
||||||
{root + "/Project-A", "landing", false},
|
{root + "/Project-A", "landing", false},
|
||||||
|
|
||||||
// classifier: per-party working/, staging/, incoming/ subtrees
|
// classifier: working/, staging/, archive/<party>/incoming/ and subtrees
|
||||||
{root, "classifier", false},
|
{root, "classifier", false},
|
||||||
{root + "/Project-A", "classifier", false},
|
{root + "/Project-A", "classifier", false},
|
||||||
{root + "/Project-A/archive/ACME/working", "classifier", true},
|
{root + "/Project-A/working", "classifier", true},
|
||||||
{root + "/Project-A/archive/ACME/working/deep/nested/path", "classifier", true},
|
{root + "/Project-A/working/deep/nested/path", "classifier", true},
|
||||||
{root + "/Project-A/archive/ACME/staging", "classifier", true},
|
{root + "/Project-A/staging", "classifier", true},
|
||||||
{root + "/Project-A/archive/ACME/staging/2026-06-15_x (DFT) - y", "classifier", true},
|
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "classifier", true},
|
||||||
{root + "/Project-A/archive/ACME/incoming", "classifier", true},
|
{root + "/Project-A/archive/ACME/incoming", "classifier", true},
|
||||||
{root + "/Project-A/archive/ACME/incoming/sub", "classifier", true},
|
{root + "/Project-A/archive/ACME/incoming/sub", "classifier", true},
|
||||||
{root + "/Project-A/archive/ACME/received", "classifier", false},
|
{root + "/Project-A/archive/ACME/received", "classifier", false},
|
||||||
|
|
@ -37,20 +37,20 @@ func TestAppAvailableAt(t *testing.T) {
|
||||||
|
|
||||||
// browse: universal — every directory has browse available
|
// browse: universal — every directory has browse available
|
||||||
// (it's in the embedded-defaults baseline available_tools).
|
// (it's in the embedded-defaults baseline available_tools).
|
||||||
{root + "/Project-A/archive/ACME/working", "browse", true},
|
{root + "/Project-A/working", "browse", true},
|
||||||
{root + "/Project-A/archive/ACME/working/sub", "browse", true},
|
{root + "/Project-A/working/sub", "browse", true},
|
||||||
{root + "/Project-A/archive/ACME/staging", "browse", true},
|
{root + "/Project-A/staging", "browse", true},
|
||||||
{root + "/Project-A/archive/ACME/incoming", "browse", true},
|
{root + "/Project-A/archive/ACME/incoming", "browse", true},
|
||||||
|
|
||||||
// transmittal: per-party staging/ only
|
// transmittal: staging/ only
|
||||||
{root + "/Project-A/archive/ACME/staging", "transmittal", true},
|
{root + "/Project-A/staging", "transmittal", true},
|
||||||
{root + "/Project-A/archive/ACME/staging/sub", "transmittal", true},
|
{root + "/Project-A/staging/sub", "transmittal", true},
|
||||||
{root + "/Project-A/archive/ACME/working", "transmittal", false},
|
{root + "/Project-A/working", "transmittal", false},
|
||||||
{root + "/Project-A/archive/ACME/issued", "transmittal", false},
|
{root + "/Project-A/archive/ACME/issued", "transmittal", false},
|
||||||
|
|
||||||
// case-fold: any case of canonical names matches
|
// case-fold: any case of canonical names matches
|
||||||
{root + "/Project-A/archive/ACME/Staging", "transmittal", true},
|
{root + "/Project-A/Staging", "transmittal", true},
|
||||||
{root + "/Project-A/archive/ACME/STAGING", "transmittal", true},
|
{root + "/Project-A/STAGING", "transmittal", true},
|
||||||
{root + "/Project-A/archive/ACME/Incoming", "classifier", true},
|
{root + "/Project-A/archive/ACME/Incoming", "classifier", true},
|
||||||
{root + "/Project-A/Archive/ACME/incoming", "classifier", true},
|
{root + "/Project-A/Archive/ACME/incoming", "classifier", true},
|
||||||
|
|
||||||
|
|
@ -79,20 +79,12 @@ func TestDefaultAppAt(t *testing.T) {
|
||||||
// Bare project root: no default. Trailing-slash URL serves browse;
|
// Bare project root: no default. Trailing-slash URL serves browse;
|
||||||
// no-slash falls through to the redirect.
|
// no-slash falls through to the redirect.
|
||||||
{root + "/Project-A", ""},
|
{root + "/Project-A", ""},
|
||||||
// Project-level virtual aggregators (sibling to archive/).
|
// Canonical project-root folders.
|
||||||
{root + "/Project-A/working", "browse"},
|
{root + "/Project-A/working", "browse"},
|
||||||
{root + "/Project-A/staging", "browse"},
|
{root + "/Project-A/working/alice@example.com", "browse"},
|
||||||
{root + "/Project-A/reviewing", "browse"},
|
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "browse"},
|
||||||
{root + "/Project-A/ssr", "tables"},
|
{root + "/Project-A/staging", "transmittal"},
|
||||||
{root + "/Project-A/mdl", "tables"},
|
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"},
|
||||||
{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.
|
// archive: at the archive root, party folders default to archive.
|
||||||
// Per-party subfolders override per their function:
|
// Per-party subfolders override per their function:
|
||||||
// incoming → classifier (the bulk-rename workflow)
|
// incoming → classifier (the bulk-rename workflow)
|
||||||
|
|
@ -102,15 +94,20 @@ func TestDefaultAppAt(t *testing.T) {
|
||||||
{root + "/Project-A/archive/Acme/incoming", "classifier"},
|
{root + "/Project-A/archive/Acme/incoming", "classifier"},
|
||||||
{root + "/Project-A/archive/Acme/issued", "archive"},
|
{root + "/Project-A/archive/Acme/issued", "archive"},
|
||||||
{root + "/Project-A/archive/Acme/received", "archive"},
|
{root + "/Project-A/archive/Acme/received", "archive"},
|
||||||
// mdl/rsk win over the broader archive rule.
|
// mdl wins over the broader archive rule.
|
||||||
{root + "/Project-A/archive/Acme/mdl", "tables"},
|
{root + "/Project-A/archive/Acme/mdl", "tables"},
|
||||||
{root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"},
|
{root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"},
|
||||||
{root + "/Project-A/archive/Acme/rsk", "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"},
|
||||||
// Random non-canonical folder names → no default.
|
// Random non-canonical folder names → no default.
|
||||||
{root + "/Project-A/scratch", ""},
|
{root + "/Project-A/scratch", ""},
|
||||||
// Case-fold on canonical names.
|
// Case-fold on canonical names.
|
||||||
{root + "/Project-A/archive/Acme/Working", "browse"},
|
{root + "/Project-A/Working", "browse"},
|
||||||
{root + "/Project-A/archive/Acme/STAGING", "transmittal"},
|
{root + "/Project-A/STAGING", "transmittal"},
|
||||||
{root + "/Project-A/Archive/Acme/MDL", "tables"},
|
{root + "/Project-A/Archive/Acme/MDL", "tables"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|
|
||||||
|
|
@ -133,8 +133,8 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
subURLPath := baseURL + name + "/"
|
subURLPath := baseURL + name + "/"
|
||||||
subVerbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, subURLPath)
|
allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, subURLPath)
|
||||||
if !subVerbs.Has(zddc.VerbR) {
|
if !allowed {
|
||||||
continue // omit denied directories silently
|
continue // omit denied directories silently
|
||||||
}
|
}
|
||||||
// Pull the title from this subdir's own .zddc, if it has
|
// Pull the title from this subdir's own .zddc, if it has
|
||||||
|
|
@ -156,7 +156,6 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
Declared: declared,
|
Declared: declared,
|
||||||
Title: title,
|
Title: title,
|
||||||
Verbs: subVerbs.String(),
|
|
||||||
}
|
}
|
||||||
result = append(result, fi)
|
result = append(result, fi)
|
||||||
continue
|
continue
|
||||||
|
|
@ -173,58 +172,55 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
Declared: declared,
|
Declared: declared,
|
||||||
}
|
}
|
||||||
// Verbs surfaces what the principal can do at this file's URL,
|
// Writable surfaces whether THIS principal could PUT this file
|
||||||
// computed against the parent-dir chain (files inherit from
|
// — same decision as the file API's authorizeAction would
|
||||||
// parent; they have no .zddc of their own). Writable is the
|
// reach. Uses the parent-dir chain (computed once above);
|
||||||
// legacy single-bit projection — it stays in lockstep with
|
// active-admin status short-circuits the per-file decider
|
||||||
// the verbs string for the transition window. For .zddc files
|
// query when the principal already holds admin authority.
|
||||||
// the legacy gate maps Writable to the admin verb (a) instead
|
// .zddc requires ActionAdmin (not ActionWrite) so the verb
|
||||||
// of write (w), matching fileapi.go's ActionAdmin gate at
|
// matches the file API's gate at fileapi.go:362-364.
|
||||||
// the .zddc URL.
|
action := policy.ActionWrite
|
||||||
fileURL := baseURL + name
|
|
||||||
fileVerbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, fileURL)
|
|
||||||
fi.Verbs = fileVerbs.String()
|
|
||||||
writableBit := zddc.VerbW
|
|
||||||
if name == ".zddc" {
|
if name == ".zddc" {
|
||||||
writableBit = zddc.VerbA
|
action = policy.ActionAdmin
|
||||||
|
}
|
||||||
|
fileURL := baseURL + name
|
||||||
|
if parentActiveAdmin {
|
||||||
|
fi.Writable = true
|
||||||
|
} else {
|
||||||
|
allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, action)
|
||||||
|
if allowed {
|
||||||
|
fi.Writable = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin
|
|
||||||
result = append(result, fi)
|
result = append(result, fi)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-user virtual home: when listing
|
// Per-user virtual home: when listing <project>/working/ for an
|
||||||
// <project>/archive/<party>/working/ for an authenticated viewer,
|
// authenticated viewer, surface a synthetic <viewer-email>/ entry if
|
||||||
// surface a synthetic <viewer-email>/ entry if no real folder of
|
// no real folder of any case variant already exists for them. A
|
||||||
// any case variant already exists for them. A first write to that
|
// first write to that path materialises a real folder with auto-own
|
||||||
// path materialises a real folder with auto-own .zddc; subsequent
|
// .zddc; subsequent listings drop the synthetic entry naturally.
|
||||||
// listings drop the synthetic entry naturally.
|
if syn, ok := virtualUserHomeEntry(fsRoot, dirPath, userEmail, baseURL, result); ok {
|
||||||
if syn, ok := virtualUserHomeEntry(ctx, decider, fsRoot, dirPath, principal, baseURL, result); ok {
|
|
||||||
result = append(result, syn)
|
result = append(result, syn)
|
||||||
}
|
}
|
||||||
|
|
||||||
// At a project root, surface the cascade-declared top-level
|
// At a project root, surface the four canonical project folders
|
||||||
// folders (archive plus the six virtual aggregators) as virtual
|
// (archive/working/staging/reviewing) as virtual entries when no
|
||||||
// entries when no on-disk variant exists. The browse client
|
// on-disk variant exists in any case. The browse client previously
|
||||||
// previously did this client-side; moving it server-side lets the
|
// did this client-side; moving it server-side lets the directory's
|
||||||
// directory's `display:` map apply to virtual entries the same
|
// `display:` map apply to virtual entries the same way it applies
|
||||||
// way it applies to real ones.
|
// to real ones.
|
||||||
result = append(result, virtualCanonicalFolders(ctx, decider, fsRoot, absDir, principal, baseURL, result, displayMap)...)
|
result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...)
|
||||||
|
|
||||||
// Project-level virtual views:
|
// Project-level virtual table views: SSR aggregates one row per
|
||||||
//
|
// party folder under archive/; MDL/RSK rollups aggregate every
|
||||||
// Row rollups (ssr/mdl/rsk) — synthesize row entries (Writable
|
// row from each party's mdl/ or rsk/. The listing surfaces
|
||||||
// bit per the canonical archive/<party>/ chain) plus synthetic
|
// synthetic row entries (Writable bit per the canonical
|
||||||
// table.yaml/form.yaml entries so the tables tool's client-side
|
// archive/<party>/ chain) plus synthetic table.yaml/form.yaml
|
||||||
// walkServer finds the spec without a 404 round-trip. Spec bytes
|
// entries so the tables tool's client-side walkServer finds the
|
||||||
// come from main.go IsDefaultSpec fallback; row reads go through
|
// spec without a 404 round-trip. Spec bytes are served by the
|
||||||
// handler.ServeVirtualViewRow which path-injects name/$party.
|
// 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() {
|
if vv := zddc.ResolveVirtualView(fsRoot, strings.TrimSuffix(baseURL, "/")); vv.Resolved && vv.Kind.IsRootKind() {
|
||||||
partyChains := make(map[string]zddc.PolicyChain)
|
partyChains := make(map[string]zddc.PolicyChain)
|
||||||
chainFor := func(partyAbs string) zddc.PolicyChain {
|
chainFor := func(partyAbs string) zddc.PolicyChain {
|
||||||
|
|
@ -238,32 +234,22 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
appendVirtualRow := func(syntheticName, partyAbs string) {
|
appendVirtualRow := func(syntheticName, partyAbs string) {
|
||||||
rowURL := baseURL + url.PathEscape(syntheticName)
|
rowURL := baseURL + url.PathEscape(syntheticName)
|
||||||
chain := chainFor(partyAbs)
|
chain := chainFor(partyAbs)
|
||||||
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, rowURL)
|
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, rowURL); !allowed {
|
||||||
if !verbs.Has(zddc.VerbR) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
partyActiveAdmin := elevated && userEmail != "" &&
|
||||||
|
zddc.IsAdminForChain(chain, userEmail)
|
||||||
|
writable := partyActiveAdmin
|
||||||
|
if !writable {
|
||||||
|
allowed, _ := policy.AllowActionFromChainP(ctx, decider, chain, principal, rowURL, policy.ActionWrite)
|
||||||
|
writable = allowed
|
||||||
|
}
|
||||||
result = append(result, listing.FileInfo{
|
result = append(result, listing.FileInfo{
|
||||||
Name: syntheticName,
|
Name: syntheticName,
|
||||||
URL: rowURL,
|
URL: rowURL,
|
||||||
IsDir: false,
|
IsDir: false,
|
||||||
Virtual: true,
|
Virtual: true,
|
||||||
Writable: verbs.Has(zddc.VerbW),
|
Writable: writable,
|
||||||
Verbs: verbs.String(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
appendVirtualPartyDir := func(party, partyAbs string) {
|
|
||||||
dirURL := baseURL + url.PathEscape(party) + "/"
|
|
||||||
chain := chainFor(partyAbs)
|
|
||||||
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, dirURL)
|
|
||||||
if !verbs.Has(zddc.VerbR) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result = append(result, listing.FileInfo{
|
|
||||||
Name: party + "/",
|
|
||||||
URL: dirURL,
|
|
||||||
IsDir: true,
|
|
||||||
Virtual: true,
|
|
||||||
Verbs: verbs.String(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,23 +266,12 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
partyAbs := filepath.Join(vv.ProjectAbs, "archive", row.Party)
|
partyAbs := filepath.Join(vv.ProjectAbs, "archive", row.Party)
|
||||||
appendVirtualRow(row.SyntheticName, partyAbs)
|
appendVirtualRow(row.SyntheticName, partyAbs)
|
||||||
}
|
}
|
||||||
case "working", "staging", "reviewing":
|
|
||||||
parties, _ := zddc.ListPartyDirsInSlot(fsRoot, vv.ProjectAbs, vv.Slot)
|
|
||||||
for _, party := range parties {
|
|
||||||
partyAbs := filepath.Join(vv.ProjectAbs, "archive", party)
|
|
||||||
appendVirtualPartyDir(party, partyAbs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Row rollups carry synthetic spec entries so the tables tool
|
result = append(result,
|
||||||
// can walkServer them. Folder-nav virtuals don't need spec
|
listing.FileInfo{Name: "table.yaml", URL: baseURL + "table.yaml", IsDir: false, Virtual: true},
|
||||||
// files — they're just party listings rendered by browse.
|
listing.FileInfo{Name: "form.yaml", URL: baseURL + "form.yaml", IsDir: false, Virtual: true},
|
||||||
if zddc.IsRowSlot(vv.Slot) {
|
)
|
||||||
result = append(result,
|
|
||||||
listing.FileInfo{Name: "table.yaml", URL: baseURL + "table.yaml", IsDir: false, Virtual: true, Verbs: zddc.VerbR.String()},
|
|
||||||
listing.FileInfo{Name: "form.yaml", URL: baseURL + "form.yaml", IsDir: false, Virtual: true, Verbs: zddc.VerbR.String()},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workflow folder: append a virtual `received/` entry whose backing
|
// Workflow folder: append a virtual `received/` entry whose backing
|
||||||
|
|
@ -315,22 +290,12 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hasReal {
|
if !hasReal {
|
||||||
receivedURL := baseURL + "received/"
|
result = append(result, listing.FileInfo{
|
||||||
// Verbs against the canonical workflow's chain — the
|
Name: "received/",
|
||||||
// virtual `received/` resolves to a read-through window
|
URL: baseURL + "received/",
|
||||||
// onto received/<tracking>/; writes go through serveFilePut
|
IsDir: true,
|
||||||
// which rewrites to a +Cn revision. Read is the only verb
|
Virtual: true,
|
||||||
// surfaced here.
|
})
|
||||||
vrVerbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, receivedURL)
|
|
||||||
if vrVerbs.Has(zddc.VerbR) {
|
|
||||||
result = append(result, listing.FileInfo{
|
|
||||||
Name: "received/",
|
|
||||||
URL: receivedURL,
|
|
||||||
IsDir: true,
|
|
||||||
Virtual: true,
|
|
||||||
Verbs: (vrVerbs & zddc.VerbR).String(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,8 +327,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
// chain, short-circuited when the principal already holds admin
|
// chain, short-circuited when the principal already holds admin
|
||||||
// authority. An elevated admin sees writable=true and the editor lets
|
// authority. An elevated admin sees writable=true and the editor lets
|
||||||
// them save; a non-admin sees writable=false and the editor mounts
|
// them save; a non-admin sees writable=false and the editor mounts
|
||||||
// read-only. Verbs carries the full verb set so a client can also gate
|
// read-only.
|
||||||
// other affordances (e.g. delete on the editor's toolbar).
|
|
||||||
func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain zddc.PolicyChain, principal zddc.Principal, parentActiveAdmin bool, absDir, baseURL string) (listing.FileInfo, bool) {
|
func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain zddc.PolicyChain, principal zddc.Principal, parentActiveAdmin bool, absDir, baseURL string) (listing.FileInfo, bool) {
|
||||||
zddcPath := filepath.Join(absDir, ".zddc")
|
zddcPath := filepath.Join(absDir, ".zddc")
|
||||||
if _, err := os.Stat(zddcPath); err == nil {
|
if _, err := os.Stat(zddcPath); err == nil {
|
||||||
|
|
@ -371,14 +335,17 @@ func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain z
|
||||||
} else if !os.IsNotExist(err) {
|
} else if !os.IsNotExist(err) {
|
||||||
return listing.FileInfo{}, false
|
return listing.FileInfo{}, false
|
||||||
}
|
}
|
||||||
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, baseURL+".zddc")
|
writable := parentActiveAdmin
|
||||||
|
if !writable {
|
||||||
|
allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, baseURL+".zddc", policy.ActionAdmin)
|
||||||
|
writable = allowed
|
||||||
|
}
|
||||||
return listing.FileInfo{
|
return listing.FileInfo{
|
||||||
Name: ".zddc",
|
Name: ".zddc",
|
||||||
URL: baseURL + ".zddc",
|
URL: baseURL + ".zddc",
|
||||||
IsDir: false,
|
IsDir: false,
|
||||||
Virtual: true,
|
Virtual: true,
|
||||||
Writable: verbs.Has(zddc.VerbA) || parentActiveAdmin,
|
Writable: writable,
|
||||||
Verbs: verbs.String(),
|
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -390,10 +357,8 @@ func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain z
|
||||||
// incoming, received, issued under archive/<party>/; whatever an
|
// incoming, received, issued under archive/<party>/; whatever an
|
||||||
// operator added via on-disk .zddc paths:). Case-insensitive
|
// operator added via on-disk .zddc paths:). Case-insensitive
|
||||||
// presence check suppresses a virtual entry when the on-disk
|
// presence check suppresses a virtual entry when the on-disk
|
||||||
// directory exists in any case. Verbs are computed against each
|
// directory exists in any case.
|
||||||
// synthetic child's would-be chain so client-side gating matches
|
func virtualCanonicalFolders(fsRoot, absDir, baseURL string,
|
||||||
// what a real on-disk folder would carry.
|
|
||||||
func virtualCanonicalFolders(ctx context.Context, decider policy.Decider, fsRoot, absDir string, principal zddc.Principal, baseURL string,
|
|
||||||
real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo {
|
real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo {
|
||||||
|
|
||||||
declared := zddc.ChildrenDeclaredAt(fsRoot, absDir)
|
declared := zddc.ChildrenDeclaredAt(fsRoot, absDir)
|
||||||
|
|
@ -415,54 +380,34 @@ func virtualCanonicalFolders(ctx context.Context, decider policy.Decider, fsRoot
|
||||||
if present[strings.ToLower(name)] {
|
if present[strings.ToLower(name)] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
childAbs := filepath.Join(absDir, name)
|
|
||||||
chain, err := zddc.EffectivePolicy(fsRoot, childAbs)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
childURL := baseURL + url.PathEscape(name) + "/"
|
|
||||||
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, childURL)
|
|
||||||
if !verbs.Has(zddc.VerbR) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
synth = append(synth, listing.FileInfo{
|
synth = append(synth, listing.FileInfo{
|
||||||
Name: name + "/",
|
Name: name + "/",
|
||||||
URL: childURL,
|
URL: baseURL + url.PathEscape(name) + "/",
|
||||||
IsDir: true,
|
IsDir: true,
|
||||||
Virtual: true,
|
Virtual: true,
|
||||||
DisplayName: lookupDisplay(displayMap, name),
|
DisplayName: lookupDisplay(displayMap, name),
|
||||||
Declared: true, // synthesized entries are by definition cascade-declared
|
Declared: true, // synthesized entries are by definition cascade-declared
|
||||||
Verbs: verbs.String(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return synth
|
return synth
|
||||||
}
|
}
|
||||||
|
|
||||||
// virtualUserHomeEntry returns the synthetic <viewer-email>/ entry that
|
// virtualUserHomeEntry returns the synthetic <viewer-email>/ entry that
|
||||||
// should be appended to a per-party working/ listing, or (zero, false)
|
// should be appended to a working/ listing, or (zero, false) when no
|
||||||
// when no synthetic entry applies.
|
// 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:
|
// Conditions for the entry to fire:
|
||||||
// - dirPath case-folds to <project>/archive/<party>/working at
|
// - dirPath case-folds to <project>/working at depth-2 of fsRoot
|
||||||
// depth-4 of fsRoot
|
|
||||||
// - viewerEmail is non-empty
|
// - viewerEmail is non-empty
|
||||||
// - real does not already contain a directory entry that case-folds
|
// - real does not already contain a directory entry that case-folds
|
||||||
// to viewerEmail (so a materialised home doesn't get duplicated)
|
// to viewerEmail (so a materialised home doesn't get duplicated)
|
||||||
func virtualUserHomeEntry(ctx context.Context, decider policy.Decider, fsRoot, dirPath string, principal zddc.Principal, baseURL string, real []listing.FileInfo) (listing.FileInfo, bool) {
|
func virtualUserHomeEntry(fsRoot, dirPath, viewerEmail, baseURL string, real []listing.FileInfo) (listing.FileInfo, bool) {
|
||||||
if principal.Email == "" {
|
if viewerEmail == "" {
|
||||||
return listing.FileInfo{}, false
|
return listing.FileInfo{}, false
|
||||||
}
|
}
|
||||||
rel := strings.Trim(filepath.ToSlash(dirPath), "/")
|
rel := strings.Trim(filepath.ToSlash(dirPath), "/")
|
||||||
parts := strings.Split(rel, "/")
|
parts := strings.Split(rel, "/")
|
||||||
if len(parts) != 4 ||
|
if len(parts) != 2 || !strings.EqualFold(parts[1], "working") {
|
||||||
!strings.EqualFold(parts[1], "archive") ||
|
|
||||||
!strings.EqualFold(parts[3], "working") {
|
|
||||||
return listing.FileInfo{}, false
|
return listing.FileInfo{}, false
|
||||||
}
|
}
|
||||||
for _, fi := range real {
|
for _, fi := range real {
|
||||||
|
|
@ -471,31 +416,15 @@ func virtualUserHomeEntry(ctx context.Context, decider policy.Decider, fsRoot, d
|
||||||
}
|
}
|
||||||
// fi.Name carries a trailing slash for dirs.
|
// fi.Name carries a trailing slash for dirs.
|
||||||
bare := strings.TrimSuffix(fi.Name, "/")
|
bare := strings.TrimSuffix(fi.Name, "/")
|
||||||
if strings.EqualFold(bare, principal.Email) {
|
if strings.EqualFold(bare, viewerEmail) {
|
||||||
return listing.FileInfo{}, false
|
return listing.FileInfo{}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Compute verbs against the would-be home's own chain — the
|
|
||||||
// auto_own_fenced declaration in defaults.zddc.yaml means a real
|
|
||||||
// home grants the creator rwcda; the synthetic entry reports the
|
|
||||||
// same so client-side gating renders the "+ New" affordances
|
|
||||||
// immediately, before the first write materialises the folder.
|
|
||||||
homeAbs := filepath.Join(fsRoot, filepath.FromSlash(dirPath), principal.Email)
|
|
||||||
chain, err := zddc.EffectivePolicy(fsRoot, homeAbs)
|
|
||||||
if err != nil {
|
|
||||||
return listing.FileInfo{}, false
|
|
||||||
}
|
|
||||||
homeURL := baseURL + url.PathEscape(principal.Email) + "/"
|
|
||||||
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, homeURL)
|
|
||||||
if !verbs.Has(zddc.VerbR) {
|
|
||||||
return listing.FileInfo{}, false
|
|
||||||
}
|
|
||||||
return listing.FileInfo{
|
return listing.FileInfo{
|
||||||
Name: principal.Email + "/",
|
Name: viewerEmail + "/",
|
||||||
URL: homeURL,
|
URL: baseURL + url.PathEscape(viewerEmail) + "/",
|
||||||
IsDir: true,
|
IsDir: true,
|
||||||
Virtual: true,
|
Virtual: true,
|
||||||
Verbs: verbs.String(),
|
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,20 +21,14 @@ func setupTreeRoot(t *testing.T) string {
|
||||||
return root
|
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) {
|
func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
|
||||||
root := setupTreeRoot(t)
|
root := setupTreeRoot(t)
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "working"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root,
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, false)
|
||||||
"Proj/archive/Acme/working", "alice@example.com",
|
|
||||||
"/Proj/archive/Acme/working/", false, false)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list: %v", err)
|
t.Fatalf("list: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -57,14 +51,12 @@ func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
|
||||||
func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
|
func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
|
||||||
root := setupTreeRoot(t)
|
root := setupTreeRoot(t)
|
||||||
// A real folder exists for the viewer (any case).
|
// A real folder exists for the viewer (any case).
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working", "Alice@Example.com"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "Alice@Example.com"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root,
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, false)
|
||||||
"Proj/archive/Acme/working", "alice@example.com",
|
|
||||||
"/Proj/archive/Acme/working/", false, false)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list: %v", err)
|
t.Fatalf("list: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -77,14 +69,12 @@ func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
|
||||||
|
|
||||||
func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
|
func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
|
||||||
root := setupTreeRoot(t)
|
root := setupTreeRoot(t)
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "working"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root,
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/", false, false)
|
||||||
"Proj/archive/Acme/working", "" /* no viewer */,
|
|
||||||
"/Proj/archive/Acme/working/", false, false)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list: %v", err)
|
t.Fatalf("list: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -97,14 +87,12 @@ func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
|
||||||
|
|
||||||
func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
|
func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
|
||||||
root := setupTreeRoot(t)
|
root := setupTreeRoot(t)
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "staging"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "staging"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root,
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/", false, false)
|
||||||
"Proj/archive/Acme/staging", "alice@example.com",
|
|
||||||
"/Proj/archive/Acme/staging/", false, false)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list: %v", err)
|
t.Fatalf("list: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -117,15 +105,15 @@ func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
|
||||||
|
|
||||||
func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
|
func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
|
||||||
root := setupTreeRoot(t)
|
root := setupTreeRoot(t)
|
||||||
// Listing inside working/<email>/ — no synthetic entry should fire.
|
// Listing inside working/ at depth 3+ — no synthetic entry should fire.
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "alice@example.com"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root,
|
got, err := ListDirectory(context.Background(), nil, root,
|
||||||
"Proj/archive/Acme/working/alice@example.com", "alice@example.com",
|
"Proj/working/alice@example.com", "alice@example.com",
|
||||||
"/Proj/archive/Acme/working/alice@example.com/", false, false)
|
"/Proj/working/alice@example.com/", false, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list: %v", err)
|
t.Fatalf("list: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -138,15 +126,13 @@ func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
|
||||||
|
|
||||||
func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
|
func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
|
||||||
root := setupTreeRoot(t)
|
root := setupTreeRoot(t)
|
||||||
// Pre-existing PascalCase Working/ under archive/<party>/.
|
// Pre-existing PascalCase Working/.
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "Working"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "Working"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root,
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/", false, false)
|
||||||
"Proj/archive/Acme/Working", "alice@example.com",
|
|
||||||
"/Proj/archive/Acme/Working/", false, false)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list: %v", err)
|
t.Fatalf("list: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -161,17 +147,14 @@ func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listing a canonical-folder path that doesn't exist on disk yet
|
// Listing a canonical project folder that doesn't exist on disk yet
|
||||||
// returns an empty slice instead of os.ErrNotExist. The stage-strip
|
// returns an empty slice instead of os.ErrNotExist. The stage-strip
|
||||||
// nav links into <project>/archive/ etc. unconditionally; this keeps
|
// nav links into <project>/working/ etc. unconditionally; this keeps
|
||||||
// fresh projects from 404'ing.
|
// fresh projects (no working/ on disk yet) 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) {
|
func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
|
||||||
root := setupTreeRoot(t)
|
root := setupTreeRoot(t)
|
||||||
// Proj exists; the party folder skeleton does not.
|
// Proj exists but Proj/working/ does NOT.
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"),
|
if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"),
|
||||||
|
|
@ -180,31 +163,29 @@ func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
|
||||||
}
|
}
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
for _, stage := range []string{"working", "staging", "reviewing", "incoming"} {
|
for _, stage := range []string{"working", "staging", "reviewing", "archive"} {
|
||||||
dirPath := "Proj/archive/Acme/" + stage
|
|
||||||
baseURL := "/" + dirPath + "/"
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root,
|
got, err := ListDirectory(context.Background(), nil, root,
|
||||||
dirPath, "alice@example.com", baseURL, false, false)
|
"Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("ListDirectory(%s) on missing dir: err = %v, want nil", dirPath, err)
|
t.Errorf("ListDirectory(Proj/%s) on missing dir: err = %v, want nil", stage, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// working/ surfaces a synthetic <viewer-email>/ entry; the
|
// working/ surfaces a synthetic <viewer-email>/ entry; the others
|
||||||
// others should be a flat empty listing.
|
// should be a flat empty listing.
|
||||||
if stage == "working" {
|
if stage == "working" {
|
||||||
if len(got) != 1 || !got[0].Virtual {
|
if len(got) != 1 || !got[0].Virtual {
|
||||||
t.Errorf("ListDirectory(%s) on missing dir: got %+v, want only the virtual home entry", dirPath, got)
|
t.Errorf("ListDirectory(Proj/working) on missing dir: got %+v, want only the virtual home entry", got)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if len(got) != 0 {
|
if len(got) != 0 {
|
||||||
t.Errorf("ListDirectory(%s) on missing dir: got %+v, want empty", dirPath, got)
|
t.Errorf("ListDirectory(Proj/%s) on missing dir: got %+v, want empty", stage, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-canonical paths still 404 (return os.ErrNotExist) — the fallback
|
// Non-canonical paths still 404 (return os.ErrNotExist) — the fallback
|
||||||
// only applies to cascade-declared paths.
|
// only applies to the four canonical project-root folders.
|
||||||
func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
|
func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
|
||||||
root := setupTreeRoot(t)
|
root := setupTreeRoot(t)
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
|
||||||
|
|
@ -223,135 +204,3 @@ func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
|
||||||
t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestListDirectory_VerbsPerEntry — every entry in a directory listing
|
|
||||||
// carries `verbs`, the canonical "rwcda" subset granted to the caller
|
|
||||||
// at that entry's URL. Files and dirs are gated against different
|
|
||||||
// chains (files use parent's, dirs use their own), so a fenced subdir
|
|
||||||
// surfaces a different verb set than its file siblings.
|
|
||||||
func TestListDirectory_VerbsPerEntry(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
// Root grants alice read across the project; bob nothing.
|
|
||||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
||||||
[]byte("acl:\n permissions:\n \"alice@example.com\": rw\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "sub"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(root, "Proj", "doc.md"), []byte("x"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Subdir extends alice's grant to include create — confirms the
|
|
||||||
// dir entry's verbs come from its OWN chain, not parent's.
|
|
||||||
if err := os.WriteFile(filepath.Join(root, "Proj", "sub", ".zddc"),
|
|
||||||
[]byte("acl:\n permissions:\n \"alice@example.com\": rwc\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(root)
|
|
||||||
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root,
|
|
||||||
"Proj", "alice@example.com", "/Proj/", false, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
wantVerbs := map[string]string{
|
|
||||||
"doc.md": "rw", // file: parent chain (project root → rw)
|
|
||||||
"sub/": "rwc", // dir: own chain (extends to rwc)
|
|
||||||
}
|
|
||||||
for _, fi := range got {
|
|
||||||
want, ok := wantVerbs[fi.Name]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if fi.Verbs != want {
|
|
||||||
t.Errorf("entry %s verbs = %q, want %q", fi.Name, fi.Verbs, want)
|
|
||||||
}
|
|
||||||
// Writable stays in lockstep with verbs for the transition
|
|
||||||
// window — w bit for files, r/c semantics for dirs (no
|
|
||||||
// Writable on dirs today; we don't assert it).
|
|
||||||
if !fi.IsDir {
|
|
||||||
wantWritable := want == "rw"
|
|
||||||
if fi.Writable != wantWritable {
|
|
||||||
t.Errorf("entry %s Writable = %v, want %v", fi.Name, fi.Writable, wantWritable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestListDirectory_VerbsActiveAdminBypass — an elevated admin sees the
|
|
||||||
// full "rwcda" verb set on every entry regardless of explicit ACL
|
|
||||||
// grants. Mirrors the InternalDecider's single bypass branch.
|
|
||||||
func TestListDirectory_VerbsActiveAdminBypass(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
||||||
[]byte("admins:\n - admin@example.com\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(root, "Proj", "doc.md"), []byte("x"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(root)
|
|
||||||
|
|
||||||
// Elevated admin sees rwcda everywhere.
|
|
||||||
got, err := ListDirectory(context.Background(), nil, root,
|
|
||||||
"Proj", "admin@example.com", "/Proj/", false, true /* elevated */)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
for _, fi := range got {
|
|
||||||
if fi.Verbs != "rwcda" {
|
|
||||||
t.Errorf("elevated admin %s verbs = %q, want rwcda", fi.Name, fi.Verbs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same admin un-elevated sees nothing (no explicit ACL grant,
|
|
||||||
// admin bypass disabled).
|
|
||||||
got, err = ListDirectory(context.Background(), nil, root,
|
|
||||||
"Proj", "admin@example.com", "/Proj/", false, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list un-elevated: %v", err)
|
|
||||||
}
|
|
||||||
for _, fi := range got {
|
|
||||||
if fi.Verbs == "rwcda" {
|
|
||||||
t.Errorf("un-elevated admin %s verbs = %q, should not be full grant", fi.Name, fi.Verbs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW
|
||||||
// non-empty bucket, 403 — never confirm the project's archive
|
// non-empty bucket, 403 — never confirm the project's archive
|
||||||
// exists to a caller with no permissions in it.
|
// exists to a caller with no permissions in it.
|
||||||
if len(result) == 0 {
|
if len(result) == 0 {
|
||||||
writeForbidden(w, policy.ActionRead)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,8 @@ import (
|
||||||
// invariantsFixture sets up a synthetic ZDDC root with:
|
// invariantsFixture sets up a synthetic ZDDC root with:
|
||||||
//
|
//
|
||||||
// - admin@example.com — root super-admin
|
// - admin@example.com — root super-admin
|
||||||
// - alice@example.com — subtree admin of Project-1/archive/Acme/working
|
// - alice@example.com — subtree admin of Project-1/working (via per-dir
|
||||||
// (via per-dir .zddc admins:) — used to test
|
// .zddc admins:) — used to test subtree scope
|
||||||
// subtree scope
|
|
||||||
// - bob@example.com — document_controller role member (gets WORM cr
|
// - bob@example.com — document_controller role member (gets WORM cr
|
||||||
// on received/ + issued/ via cascade defaults)
|
// on received/ + issued/ via cascade defaults)
|
||||||
// - eve@example.com — non-admin, project_team only (read-only across
|
// - eve@example.com — non-admin, project_team only (read-only across
|
||||||
|
|
@ -48,7 +47,7 @@ func invariantsFixture(t *testing.T) (config.Config, string) {
|
||||||
" project_team:\n members: [\"*@example.com\"]\n")
|
" project_team:\n members: [\"*@example.com\"]\n")
|
||||||
|
|
||||||
for _, d := range []string{
|
for _, d := range []string{
|
||||||
"Project-1/archive/Acme/working/eve@example.com",
|
"Project-1/working/eve@example.com",
|
||||||
"Project-1/archive/Acme/received/Acme-0042",
|
"Project-1/archive/Acme/received/Acme-0042",
|
||||||
"Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test",
|
"Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test",
|
||||||
} {
|
} {
|
||||||
|
|
@ -57,14 +56,14 @@ func invariantsFixture(t *testing.T) (config.Config, string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subtree-admin grant: alice administers Project-1/archive/Acme/working/.
|
// Subtree-admin grant: alice administers Project-1/working/.
|
||||||
mustWriteHelper(t,
|
mustWriteHelper(t,
|
||||||
filepath.Join(root, "Project-1/archive/Acme/working/.zddc"),
|
filepath.Join(root, "Project-1/working/.zddc"),
|
||||||
"admins:\n - alice@example.com\n")
|
"admins:\n - alice@example.com\n")
|
||||||
|
|
||||||
// Files to act on.
|
// Files to act on.
|
||||||
mustWriteHelper(t,
|
mustWriteHelper(t,
|
||||||
filepath.Join(root, "Project-1/archive/Acme/working/eve@example.com/draft.md"),
|
filepath.Join(root, "Project-1/working/eve@example.com/draft.md"),
|
||||||
"# eve's draft\n")
|
"# eve's draft\n")
|
||||||
mustWriteHelper(t,
|
mustWriteHelper(t,
|
||||||
filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Test.pdf"),
|
filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Test.pdf"),
|
||||||
|
|
@ -115,7 +114,7 @@ func TestInvariant_UnelevatedAdminCannotEditZddc(t *testing.T) {
|
||||||
// Exercised at the HTTP boundary: a PUT to .zddc from an un-elevated
|
// Exercised at the HTTP boundary: a PUT to .zddc from an un-elevated
|
||||||
// super-admin must return Forbidden.
|
// super-admin must return Forbidden.
|
||||||
cfg, _ := invariantsFixture(t)
|
cfg, _ := invariantsFixture(t)
|
||||||
target := "/Project-1/archive/Acme/working/.zddc"
|
target := "/Project-1/working/.zddc"
|
||||||
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("title: mutated\n"), "")
|
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("title: mutated\n"), "")
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("un-elevated admin .zddc write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("un-elevated admin .zddc write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
|
@ -127,7 +126,7 @@ func TestInvariant_ElevatedAdminCanEditZddc(t *testing.T) {
|
||||||
// .zddc. The decider's IsActiveAdmin short-circuit fires in
|
// .zddc. The decider's IsActiveAdmin short-circuit fires in
|
||||||
// AllowActionFromChainP and the file API write proceeds.
|
// AllowActionFromChainP and the file API write proceeds.
|
||||||
cfg, _ := invariantsFixture(t)
|
cfg, _ := invariantsFixture(t)
|
||||||
target := "/Project-1/archive/Acme/working/.zddc"
|
target := "/Project-1/working/.zddc"
|
||||||
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", true, []byte("title: elevated edit\n"), "")
|
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", true, []byte("title: elevated edit\n"), "")
|
||||||
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
|
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())
|
t.Fatalf("elevated admin .zddc write blocked: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
|
@ -149,9 +148,9 @@ func TestInvariant_ElevatedAdminBypassesWorm(t *testing.T) {
|
||||||
|
|
||||||
func TestInvariant_ElevatedSubtreeAdminWritesInScope(t *testing.T) {
|
func TestInvariant_ElevatedSubtreeAdminWritesInScope(t *testing.T) {
|
||||||
cfg, _ := invariantsFixture(t)
|
cfg, _ := invariantsFixture(t)
|
||||||
target := "/Project-1/archive/Acme/working/eve@example.com/draft.md"
|
target := "/Project-1/working/eve@example.com/draft.md"
|
||||||
rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# alice override\n"), "")
|
rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# alice override\n"), "")
|
||||||
// alice is subtree admin of Project-1/archive/Acme/working/ — should override eve's
|
// alice is subtree admin of Project-1/working/ — should override eve's
|
||||||
// fenced auto-own and write through.
|
// fenced auto-own and write through.
|
||||||
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
|
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())
|
t.Fatalf("elevated subtree admin write in scope blocked: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
|
@ -160,7 +159,7 @@ func TestInvariant_ElevatedSubtreeAdminWritesInScope(t *testing.T) {
|
||||||
|
|
||||||
func TestInvariant_ElevatedSubtreeAdminBlockedOutsideScope(t *testing.T) {
|
func TestInvariant_ElevatedSubtreeAdminBlockedOutsideScope(t *testing.T) {
|
||||||
cfg, _ := invariantsFixture(t)
|
cfg, _ := invariantsFixture(t)
|
||||||
// alice is subtree admin of /Project-1/archive/Acme/working/, NOT of /Project-1/archive/.
|
// alice is subtree admin of /Project-1/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"
|
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"), "")
|
rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# out-of-scope\n"), "")
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
|
|
@ -179,7 +178,7 @@ func TestInvariant_ElevatedSubtreeAdminBlockedOutsideScope(t *testing.T) {
|
||||||
func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) {
|
func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) {
|
||||||
cfg, _ := invariantsFixture(t)
|
cfg, _ := invariantsFixture(t)
|
||||||
p := zddc.Principal{Email: "alice@example.com", Elevated: true}
|
p := zddc.Principal{Email: "alice@example.com", Elevated: true}
|
||||||
dir := filepath.Join(cfg.Root, "Project-1/archive/Acme/working")
|
dir := filepath.Join(cfg.Root, "Project-1/working")
|
||||||
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
|
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("EffectivePolicy: %v", err)
|
t.Fatalf("EffectivePolicy: %v", err)
|
||||||
|
|
@ -192,7 +191,7 @@ func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) {
|
||||||
func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) {
|
func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) {
|
||||||
cfg, _ := invariantsFixture(t)
|
cfg, _ := invariantsFixture(t)
|
||||||
p := zddc.Principal{Email: "alice@example.com", Elevated: true}
|
p := zddc.Principal{Email: "alice@example.com", Elevated: true}
|
||||||
dir := filepath.Join(cfg.Root, "Project-1/archive/Acme/working/eve@example.com")
|
dir := filepath.Join(cfg.Root, "Project-1/working/eve@example.com")
|
||||||
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
|
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("EffectivePolicy: %v", err)
|
t.Fatalf("EffectivePolicy: %v", err)
|
||||||
|
|
@ -206,7 +205,7 @@ func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) {
|
||||||
|
|
||||||
func TestInvariant_EmptyEmailHasNoAuthority(t *testing.T) {
|
func TestInvariant_EmptyEmailHasNoAuthority(t *testing.T) {
|
||||||
cfg, _ := invariantsFixture(t)
|
cfg, _ := invariantsFixture(t)
|
||||||
target := "/Project-1/archive/Acme/working/eve@example.com/draft.md"
|
target := "/Project-1/working/eve@example.com/draft.md"
|
||||||
rec := doReq(cfg, http.MethodPut, target, "", true, []byte("# anon\n"), "")
|
rec := doReq(cfg, http.MethodPut, target, "", true, []byte("# anon\n"), "")
|
||||||
if rec.Code != http.StatusForbidden && rec.Code != http.StatusUnauthorized {
|
if rec.Code != http.StatusForbidden && rec.Code != http.StatusUnauthorized {
|
||||||
t.Fatalf("empty-email write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("empty-email write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
|
@ -296,7 +295,7 @@ func TestInvariant_ForwardAuthEndpointGatesOnAdminsList(t *testing.T) {
|
||||||
// - /Project-1/.zddc — project file (no on-disk .zddc;
|
// - /Project-1/.zddc — project file (no on-disk .zddc;
|
||||||
// write must materialise it; root
|
// write must materialise it; root
|
||||||
// admins still govern via cascade)
|
// admins still govern via cascade)
|
||||||
// - /Project-1/archive/Acme/working/.zddc — subtree file; alice administers
|
// - /Project-1/working/.zddc — subtree file; alice administers
|
||||||
// this subtree via its own admins:
|
// this subtree via its own admins:
|
||||||
// list (so alice's write doesn't
|
// list (so alice's write doesn't
|
||||||
// require root-admin authority).
|
// require root-admin authority).
|
||||||
|
|
@ -341,12 +340,12 @@ func TestInvariant_ZddcPutMatrix(t *testing.T) {
|
||||||
{"non-admin → project .zddc", "/Project-1/.zddc", nonAdmin, den},
|
{"non-admin → project .zddc", "/Project-1/.zddc", nonAdmin, den},
|
||||||
|
|
||||||
// Subtree .zddc (alice administers this subtree)
|
// Subtree .zddc (alice administers this subtree)
|
||||||
{"root admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminElevated, ok},
|
{"root admin elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminElevated, ok},
|
||||||
{"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, den},
|
{"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, den},
|
||||||
{"subtree admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminElevated, ok},
|
{"subtree admin elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, ok},
|
||||||
{"subtree admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, den},
|
{"subtree admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, den},
|
||||||
{"non-admin → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", nonAdmin, den},
|
{"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, den},
|
||||||
{"anonymous → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", anon, den},
|
{"anonymous → subtree .zddc", "/Project-1/working/.zddc", anon, den},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|
@ -387,11 +386,11 @@ func TestInvariant_ZddcDeleteMatrix(t *testing.T) {
|
||||||
who principal
|
who principal
|
||||||
want int
|
want int
|
||||||
}{
|
}{
|
||||||
{"root admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminElevated, http.StatusNoContent},
|
{"root admin elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminElevated, http.StatusNoContent},
|
||||||
{"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, http.StatusForbidden},
|
{"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, http.StatusForbidden},
|
||||||
{"subtree admin elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminElevated, http.StatusNoContent},
|
{"subtree admin elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, http.StatusNoContent},
|
||||||
{"subtree admin un-elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden},
|
{"subtree admin un-elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden},
|
||||||
{"non-admin → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", nonAdmin, http.StatusForbidden},
|
{"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, http.StatusForbidden},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|
@ -424,14 +423,14 @@ func TestInvariant_UnelevatedAdminNoSilentBypass(t *testing.T) {
|
||||||
probes := []op{
|
probes := []op{
|
||||||
// .zddc writes (ActionAdmin)
|
// .zddc writes (ActionAdmin)
|
||||||
{http.MethodPut, "/.zddc", []byte("title: x\n"), ""},
|
{http.MethodPut, "/.zddc", []byte("title: x\n"), ""},
|
||||||
{http.MethodPut, "/Project-1/archive/Acme/working/.zddc", []byte("title: x\n"), ""},
|
{http.MethodPut, "/Project-1/working/.zddc", []byte("title: x\n"), ""},
|
||||||
{http.MethodDelete, "/Project-1/archive/Acme/working/.zddc", nil, ""},
|
{http.MethodDelete, "/Project-1/working/.zddc", nil, ""},
|
||||||
// WORM writes (ActionWrite / ActionCreate stripped)
|
// 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/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.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, ""},
|
{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)
|
// Regular write into someone else's working/ home (no ACL grant)
|
||||||
{http.MethodPut, "/Project-1/archive/Acme/working/eve@example.com/draft.md", []byte("# steal\n"), ""},
|
{http.MethodPut, "/Project-1/working/eve@example.com/draft.md", []byte("# steal\n"), ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
admins := []struct {
|
admins := []struct {
|
||||||
|
|
|
||||||
|
|
@ -4,23 +4,21 @@
|
||||||
#
|
#
|
||||||
# This view aggregates every deliverable row from every party under
|
# This view aggregates every deliverable row from every party under
|
||||||
# <project>/archive/. Each synthetic row is backed by the real file
|
# <project>/archive/. Each synthetic row is backed by the real file
|
||||||
# at <project>/archive/<party>/mdl/<file>.yaml; the leading `$party`
|
# at <project>/archive/<party>/mdl/<file>.yaml; the leading `party`
|
||||||
# column is the server-synthesized source-party identity (path-
|
# column is derived from the row's source folder (path-injected by
|
||||||
# injected on read, not stored in the YAML). The `$` sigil marks it
|
# the server, not stored in the YAML).
|
||||||
# as system-managed — tables tool renders read-only and strips it
|
|
||||||
# before submitting a row write.
|
|
||||||
#
|
#
|
||||||
# + Add row IS enabled here: the form schema's `party` field doubles
|
# + Add row IS enabled here: the `party` column doubles as the
|
||||||
# as the routing key — the server reads the submitted `party` field,
|
# routing key — the server reads the submitted `party` field, finds
|
||||||
# finds the matching <project>/archive/<party>/ folder, and writes
|
# the matching <project>/archive/<party>/ folder, and writes the row
|
||||||
# the row inside its mdl/ subfolder. The party folder must already
|
# inside its mdl/ subfolder. The party folder must already exist
|
||||||
# exist (create it via the SSR view).
|
# (create it via the SSR view).
|
||||||
|
|
||||||
title: Project Deliverables (all parties)
|
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.
|
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:
|
columns:
|
||||||
- field: $party
|
- field: party
|
||||||
title: Package
|
title: Package
|
||||||
width: 7em
|
width: 7em
|
||||||
- field: originator
|
- field: originator
|
||||||
|
|
@ -66,5 +64,5 @@ columns:
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
sort:
|
sort:
|
||||||
- { field: $party, dir: asc }
|
- { field: party, dir: asc }
|
||||||
- { field: plannedDate, dir: asc }
|
- { field: plannedDate, dir: asc }
|
||||||
|
|
|
||||||
|
|
@ -3,23 +3,21 @@
|
||||||
#
|
#
|
||||||
# This view aggregates every risk row from every party under
|
# This view aggregates every risk row from every party under
|
||||||
# <project>/archive/. Each synthetic row is backed by the real file
|
# <project>/archive/. Each synthetic row is backed by the real file
|
||||||
# at <project>/archive/<party>/rsk/<file>.yaml; the leading `$party`
|
# at <project>/archive/<party>/rsk/<file>.yaml; the leading `party`
|
||||||
# column is the server-synthesized source-party identity (path-
|
# column is derived from the row's source folder (path-injected by
|
||||||
# injected on read, not stored in the YAML). The `$` sigil marks it
|
# the server, not stored in the YAML).
|
||||||
# as system-managed — tables tool renders read-only and strips it
|
|
||||||
# before submitting a row write.
|
|
||||||
#
|
#
|
||||||
# + Add row IS enabled here: the form schema's `party` field doubles
|
# + Add row IS enabled here: the `party` column doubles as the
|
||||||
# as the routing key — the server reads the submitted `party` field,
|
# routing key — the server reads the submitted `party` field, finds
|
||||||
# finds the matching <project>/archive/<party>/ folder, and writes
|
# the matching <project>/archive/<party>/ folder, and writes the row
|
||||||
# the row inside its rsk/ subfolder. The party folder must already
|
# inside its rsk/ subfolder. The party folder must already exist
|
||||||
# exist (create it via the SSR view).
|
# (create it via the SSR view).
|
||||||
|
|
||||||
title: Project Risk Register (all parties)
|
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.
|
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:
|
columns:
|
||||||
- field: $party
|
- field: party
|
||||||
title: Package
|
title: Package
|
||||||
width: 7em
|
width: 7em
|
||||||
- field: id
|
- field: id
|
||||||
|
|
@ -54,4 +52,4 @@ columns:
|
||||||
defaults:
|
defaults:
|
||||||
sort:
|
sort:
|
||||||
- { field: severity, dir: desc }
|
- { field: severity, dir: desc }
|
||||||
- { field: $party, dir: asc }
|
- { field: party, dir: asc }
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
|
||||||
isRoot := dirPath == ""
|
isRoot := dirPath == ""
|
||||||
if !isRoot {
|
if !isRoot {
|
||||||
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, PrincipalFromContext(r), urlPath); !allowed {
|
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, PrincipalFromContext(r), urlPath); !allowed {
|
||||||
writeForbidden(w, policy.ActionRead)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -147,16 +147,12 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
|
||||||
if dt := zddc.DefaultToolAt(cfg.Root, absDir); dt != "" {
|
if dt := zddc.DefaultToolAt(cfg.Root, absDir); dt != "" {
|
||||||
w.Header().Set("X-ZDDC-Default-Tool", dt)
|
w.Header().Set("X-ZDDC-Default-Tool", dt)
|
||||||
}
|
}
|
||||||
// X-ZDDC-On-Plan-Review surfaces whether this path is eligible for
|
// X-ZDDC-On-Plan-Review surfaces whether the cascade above this
|
||||||
// the Plan Review composite endpoint — true at every URL of the
|
// path has an on_plan_review block configured. Browse uses it to
|
||||||
// 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
|
// show/hide the "Plan Review" right-click menu item without
|
||||||
// duplicating the URL test client-side. Boolean; absent header =
|
// re-implementing the cascade client-side. Boolean; absent header
|
||||||
// false. (Replaced the previous cascade-keyed on_plan_review check
|
// = false.
|
||||||
// when the layout reshape made archive/<party>/{reviewing,staging}/
|
if zddc.OnPlanReviewAt(cfg.Root, absDir) != nil {
|
||||||
// the hardcoded scaffold target — see handler/planreview.go.)
|
|
||||||
if zddc.IsPlanReviewURL(urlPath) {
|
|
||||||
w.Header().Set("X-ZDDC-On-Plan-Review", "true")
|
w.Header().Set("X-ZDDC-On-Plan-Review", "true")
|
||||||
}
|
}
|
||||||
// X-ZDDC-Canonical-Folder names the canonical project-layout slot
|
// X-ZDDC-Canonical-Folder names the canonical project-layout slot
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
|
||||||
)
|
|
||||||
|
|
||||||
// writeForbidden emits a 403 JSON response naming the missing verb. Used
|
|
||||||
// at every ACL-deny site so the client-side toast can render a specific
|
|
||||||
// "you need <verb> here" message and offer elevation when the path-scoped
|
|
||||||
// /.profile/access?path= reports a would_elevate_grant covering that verb.
|
|
||||||
//
|
|
||||||
// Body shape:
|
|
||||||
//
|
|
||||||
// {"error": "Forbidden", "missing_verb": "w"}
|
|
||||||
//
|
|
||||||
// Existing clients that read the body as text see the JSON string instead
|
|
||||||
// of "Forbidden\n" — both are diagnostic-only display strings, no client
|
|
||||||
// in this repo parses the previous plain-text body for content. Used in
|
|
||||||
// place of `http.Error(w, "Forbidden", http.StatusForbidden)` exclusively
|
|
||||||
// for ACL-deny cases. Other 403 conditions (no authenticated principal,
|
|
||||||
// existence-leak guards, etc.) keep the plain-text variant since
|
|
||||||
// "missing_verb" doesn't apply to them.
|
|
||||||
func writeForbidden(w http.ResponseWriter, action string) {
|
|
||||||
verb := verbForAction(action)
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
|
||||||
"error": "Forbidden",
|
|
||||||
"missing_verb": verb,
|
|
||||||
})
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
_, _ = w.Write(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// verbForAction maps a policy.Action constant to its single-character
|
|
||||||
// verb. Mirrors policy.actionVerb but emits the wire-format letter
|
|
||||||
// rather than the bitmask, so the JSON body carries "r"/"w"/"c"/"d"/"a"
|
|
||||||
// — the same alphabet the listing's `verbs` field uses.
|
|
||||||
func verbForAction(action string) string {
|
|
||||||
switch action {
|
|
||||||
case policy.ActionWrite:
|
|
||||||
return "w"
|
|
||||||
case policy.ActionCreate:
|
|
||||||
return "c"
|
|
||||||
case policy.ActionDelete:
|
|
||||||
return "d"
|
|
||||||
case policy.ActionAdmin:
|
|
||||||
return "a"
|
|
||||||
default:
|
|
||||||
return "r"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -156,7 +156,7 @@ func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request,
|
||||||
decider := DeciderFromContext(r)
|
decider := DeciderFromContext(r)
|
||||||
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action)
|
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action)
|
||||||
if !allowed {
|
if !allowed {
|
||||||
writeForbidden(w, action)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
@ -694,17 +694,6 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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).
|
// Resolve canonical-folder casing on the way in (no side effects).
|
||||||
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
|
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
|
||||||
abs = r2
|
abs = r2
|
||||||
|
|
@ -770,63 +759,77 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// (The pre-reshape staging↔working mirror was retired: with
|
// Staging↔working mirror: when a folder created under staging/ matches
|
||||||
// staging at archive/<party>/staging/<batch>/ and working at
|
// the ZDDC transmittal-folder grammar AND its tracking number contains
|
||||||
// archive/<party>/working/<email>/, the project-level pairing
|
// -SUB- or -TRN-, also create the same-named folder under working/ as
|
||||||
// no longer maps cleanly. Operators who want a per-batch drafting
|
// a drafting space for staff. The mirror is one-way and one-shot —
|
||||||
// space create it inside their own working/<email>/ home.)
|
// renames or deletions of either side are not propagated.
|
||||||
|
if email != "" {
|
||||||
|
mirrorStagingToWorking(cfg, abs, email)
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("X-ZDDC-Source", "fileapi:mkdir")
|
w.Header().Set("X-ZDDC-Source", "fileapi:mkdir")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil)
|
auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// rejectProjectRootMkdir reports whether a mkdir at abs lands at
|
// mirrorStagingToWorking creates a paired drafting folder under working/
|
||||||
// <project>/<name>/ where <name> is forbidden as a direct project-
|
// when newAbs is a transmittal-named folder under <project>/staging/. Best
|
||||||
// root physical child. Under the canonical layout:
|
// effort — failures are logged but do not affect the staging mkdir result.
|
||||||
//
|
//
|
||||||
// - `archive` is the only physical project-root canonical folder
|
// Eligibility:
|
||||||
// - `_`-/`.`-prefixed names are system-reserved and allowed
|
// - newAbs's parent is exactly <project>/staging/ (case-fold)
|
||||||
// - the six virtual aggregator names (ssr/mdl/rsk/working/staging/
|
// - filepath.Base(newAbs) parses as a transmittal folder
|
||||||
// reviewing) are explicitly rejected — the virtual resolver
|
// (YYYY-MM-DD_<tracking> (<status>) - <title>)
|
||||||
// would shadow any physical folder created at those URLs
|
// - tracking contains -SUB- or -TRN- (case-fold)
|
||||||
// - 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.
|
|
||||||
//
|
//
|
||||||
// Returns (true, reason) when the request should be 409'd. Returns
|
// Side effects on success:
|
||||||
// (false, "") when the target is at any other depth or carries an
|
// - <project>/working/ created if missing, with auto-own .zddc seeded
|
||||||
// allowed name.
|
// (via EnsureCanonicalAncestors)
|
||||||
func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) {
|
// - <project>/working/<sameName>/ created if missing, with its own
|
||||||
rel, err := filepath.Rel(fsRoot, abs)
|
// 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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, ""
|
return
|
||||||
}
|
}
|
||||||
rel = filepath.ToSlash(rel)
|
rel = filepath.ToSlash(rel)
|
||||||
if rel == "." || strings.HasPrefix(rel, "../") {
|
|
||||||
return false, ""
|
|
||||||
}
|
|
||||||
parts := strings.Split(rel, "/")
|
parts := strings.Split(rel, "/")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 3 {
|
||||||
// Not a direct project-root child — depth-2 = <project>/<name>.
|
// Mirror only fires for direct children of staging/. Deeper paths
|
||||||
return false, ""
|
// (staging/<name>/sub/) are user-managed.
|
||||||
|
return
|
||||||
}
|
}
|
||||||
name := parts[1]
|
if !strings.EqualFold(parts[1], "staging") {
|
||||||
if name == "archive" {
|
return
|
||||||
return false, ""
|
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(name, "_") || strings.HasPrefix(name, ".") {
|
|
||||||
// System-reserved namespace; allowed.
|
name := parts[2]
|
||||||
return false, ""
|
_, tracking, _, _, ok := zddc.ParseTransmittalFolder(name)
|
||||||
|
if !ok || !zddc.IsTrnOrSubTracking(tracking) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
lower := strings.ToLower(name)
|
|
||||||
switch lower {
|
mirrorPath := filepath.Join(cfg.Root, parts[0], "working", name)
|
||||||
case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
|
// Idempotent: skip if the working sibling already exists.
|
||||||
return true, "Conflict — " + lower + "/ is a project-level virtual aggregator and cannot be created as a physical folder. Files of this kind live under archive/<party>/" + lower + "/."
|
if 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)
|
||||||
}
|
}
|
||||||
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.
|
// auditFile emits a structured log line for each file API operation.
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -147,54 +147,6 @@ func TestFileAPI_PutDenyForbidden(t *testing.T) {
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 403 body carries JSON with the missing verb so the client toast
|
|
||||||
// can render "you need <verb> here" and offer elevation when the
|
|
||||||
// path-scoped /.profile/access reports an elevation grant. PUT to
|
|
||||||
// a path with no existing file is gated on `c` (create); a PUT
|
|
||||||
// over an existing file would gate on `w` instead — covered by
|
|
||||||
// the test below.
|
|
||||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
|
|
||||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
MissingVerb string `json:"missing_verb"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
||||||
t.Fatalf("decode 403 body: %v (raw: %s)", err, rec.Body.String())
|
|
||||||
}
|
|
||||||
if body.MissingVerb != "c" {
|
|
||||||
t.Errorf("missing_verb = %q, want c (PUT to non-existing file gates on create)", body.MissingVerb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFileAPI_PutDenyForbiddenOverwriteVerb — PUT over an existing file
|
|
||||||
// gates on the write verb, so 403 reports missing_verb=w. Mirrors
|
|
||||||
// TestFileAPI_PutDenyForbidden but with a seeded file.
|
|
||||||
func TestFileAPI_PutDenyForbiddenOverwriteVerb(t *testing.T) {
|
|
||||||
cfg, do, _ := fileAPITestSetup(t, nil, map[string]string{
|
|
||||||
"Working/seeded.md": "before",
|
|
||||||
})
|
|
||||||
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"),
|
|
||||||
[]byte("acl:\n permissions:\n \"*@allowed.com\": rwcd\n"), 0o644); err != nil {
|
|
||||||
t.Fatalf("rewrite .zddc: %v", err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(cfg.Root)
|
|
||||||
|
|
||||||
rec := do(http.MethodPut, "/Working/seeded.md", "alice@example.com", []byte("after"), nil)
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
MissingVerb string `json:"missing_verb"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
||||||
t.Fatalf("decode 403 body: %v", err)
|
|
||||||
}
|
|
||||||
if body.MissingVerb != "w" {
|
|
||||||
t.Errorf("missing_verb = %q, want w (PUT over existing file gates on write)", body.MissingVerb)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileAPI_PutHiddenSegmentRejected(t *testing.T) {
|
func TestFileAPI_PutHiddenSegmentRejected(t *testing.T) {
|
||||||
|
|
@ -354,18 +306,15 @@ func TestFileAPI_PostMissingOp(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileAPI_MkdirCreates(t *testing.T) {
|
func TestFileAPI_MkdirCreates(t *testing.T) {
|
||||||
// Project-root mkdir is restricted to archive/ + system names
|
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||||
// 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, "/Proj/archive/Acme/incoming/newfolder/", "alice@example.com", nil, map[string]string{
|
rec := do(http.MethodPost, "/Incoming/newfolder/", "alice@example.com", nil, map[string]string{
|
||||||
"X-ZDDC-Op": "mkdir",
|
"X-ZDDC-Op": "mkdir",
|
||||||
})
|
})
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
info, err := os.Stat(filepath.Join(root, "Proj/archive/Acme/incoming/newfolder"))
|
info, err := os.Stat(filepath.Join(root, "Incoming/newfolder"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("stat: %v", err)
|
t.Fatalf("stat: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -375,8 +324,8 @@ func TestFileAPI_MkdirCreates(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileAPI_MkdirIdempotent(t *testing.T) {
|
func TestFileAPI_MkdirIdempotent(t *testing.T) {
|
||||||
_, do, _ := fileAPITestSetup(t, []string{"Proj/archive/Acme/incoming/exists"}, nil)
|
_, do, _ := fileAPITestSetup(t, []string{"Incoming/exists"}, nil)
|
||||||
rec := do(http.MethodPost, "/Proj/archive/Acme/incoming/exists/", "alice@example.com", nil, map[string]string{
|
rec := do(http.MethodPost, "/Incoming/exists/", "alice@example.com", nil, map[string]string{
|
||||||
"X-ZDDC-Op": "mkdir",
|
"X-ZDDC-Op": "mkdir",
|
||||||
})
|
})
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
|
|
@ -384,41 +333,6 @@ 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) {
|
func TestFileAPI_IfMatchEnforced(t *testing.T) {
|
||||||
_, do, _ := fileAPITestSetup(t, nil, map[string]string{
|
_, do, _ := fileAPITestSetup(t, nil, map[string]string{
|
||||||
"Incoming/x.txt": "v1",
|
"Incoming/x.txt": "v1",
|
||||||
|
|
@ -716,7 +630,145 @@ func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// (The pre-reshape staging↔working mirror was retired: with staging at
|
// --- staging↔working mirror -------------------------------------------------
|
||||||
// archive/<party>/staging/<batch>/ and working at archive/<party>/
|
|
||||||
// working/<email>/, the project-level pairing no longer maps cleanly.
|
// stagingMirrorURL builds a URL-safe target path for a transmittal folder
|
||||||
// Tests for the removed behaviour have been deleted.)
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,9 @@ import (
|
||||||
// cascade defaults; the same `c` (write-once-create) verb that
|
// cascade defaults; the same `c` (write-once-create) verb that
|
||||||
// lets them file canonical submittals lets them establish this
|
// lets them file canonical submittals lets them establish this
|
||||||
// .zddc once.
|
// .zddc once.
|
||||||
// - ActionAdmin on archive/<party>/reviewing/.zddc and
|
// - ActionAdmin on reviewing_root/.zddc + staging_root/.zddc. The
|
||||||
// archive/<party>/staging/.zddc. The invoker must already
|
// invoker must already administer those subtrees per the cascade
|
||||||
// administer those subtrees per the cascade defaults (which give
|
// defaults.
|
||||||
// subtree-admin of the party folder to document_controller).
|
|
||||||
//
|
//
|
||||||
// Operation:
|
// Operation:
|
||||||
//
|
//
|
||||||
|
|
@ -132,31 +131,23 @@ func servePlanReview(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
||||||
//
|
//
|
||||||
// Exposed so accept-transmittal can chain Plan Review in the same
|
// Exposed so accept-transmittal can chain Plan Review in the same
|
||||||
// request without round-tripping through HTTP.
|
// 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) {
|
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))
|
receivedRel := filepath.ToSlash(filepath.Join("archive", party, "received", tracking))
|
||||||
receivedAbs := filepath.Join(cfg.Root, project, filepath.FromSlash(receivedRel))
|
receivedAbs := filepath.Join(cfg.Root, project, filepath.FromSlash(receivedRel))
|
||||||
cleanURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"
|
cleanURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"
|
||||||
|
|
||||||
// Hardcoded path convention. Every project has exactly one
|
prCfg := zddc.OnPlanReviewAt(cfg.Root, receivedAbs)
|
||||||
// reviewing/ and one staging/ slot per party at fixed offsets;
|
if prCfg == nil || prCfg.ReviewingRoot == "" || prCfg.StagingRoot == "" {
|
||||||
// the composite endpoint scaffolds inside the originating party's
|
return nil, http.StatusConflict, "Conflict — on_plan_review is not configured in the cascade for this subtree"
|
||||||
// slots.
|
}
|
||||||
reviewingRoot := filepath.Join(cfg.Root, project, "archive", party, "reviewing")
|
reviewingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.ReviewingRoot, "/")))
|
||||||
stagingRoot := filepath.Join(cfg.Root, project, "archive", party, "staging")
|
stagingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.StagingRoot, "/")))
|
||||||
|
|
||||||
// Pre-flight authorisation. No ACL exception — we use existing
|
// Pre-flight authorisation. No ACL exception — we use existing
|
||||||
// cascade grants:
|
// cascade grants:
|
||||||
// (a) ActionAdmin on archive/<party>/reviewing/ and
|
// (a) ActionAdmin on reviewing_root and staging_root proves the
|
||||||
// archive/<party>/staging/ proves the invoker is subtree-
|
// invoker is subtree-admin of the workflow roots and can
|
||||||
// admin of the workflow roots (inherited from the per-party
|
// write the workflow .zddc files.
|
||||||
// `admins: [document_controller]` in the cascade defaults)
|
|
||||||
// and can write the workflow .zddc files.
|
|
||||||
// (b) The invoker has `c` (write-once-create) authority on
|
// (b) The invoker has `c` (write-once-create) authority on
|
||||||
// received/<tracking>/. For the doc_controller this comes
|
// received/<tracking>/. For the doc_controller this comes
|
||||||
// from `worm: [document_controller]` on received/ in the
|
// from `worm: [document_controller]` on received/ in the
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ func TestPlanReview_Idempotent(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm no duplicate folders snuck in.
|
// Confirm no duplicate folders snuck in.
|
||||||
reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
|
reviewingRoot := filepath.Join(root, "Project-1", "reviewing")
|
||||||
entries, err := os.ReadDir(reviewingRoot)
|
entries, err := os.ReadDir(reviewingRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read %s: %v", reviewingRoot, err)
|
t.Fatalf("read %s: %v", reviewingRoot, err)
|
||||||
|
|
@ -247,7 +247,7 @@ func TestPlanReview_ReceivedZddcIsWriteOnce(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// reviewing/.zddc reflects the new review_lead.
|
// reviewing/.zddc reflects the new review_lead.
|
||||||
reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
|
reviewingRoot := filepath.Join(root, "Project-1", "reviewing")
|
||||||
entries, err := os.ReadDir(reviewingRoot)
|
entries, err := os.ReadDir(reviewingRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read %s: %v", reviewingRoot, err)
|
t.Fatalf("read %s: %v", reviewingRoot, err)
|
||||||
|
|
@ -274,12 +274,11 @@ func TestPlanReview_Forbidden(t *testing.T) {
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("status=%d, want 403; body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status=%d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
|
if _, err := os.Stat(filepath.Join(root, "Project-1", "reviewing")); err == nil {
|
||||||
if _, err := os.Stat(reviewingRoot); err == nil {
|
|
||||||
// reviewing/ should not have been materialised. The mkdir
|
// reviewing/ should not have been materialised. The mkdir
|
||||||
// happens AFTER the ACL check in the handler, so refusal
|
// happens AFTER the ACL check in the handler, so refusal
|
||||||
// guarantees no state change.
|
// guarantees no state change.
|
||||||
entries, _ := os.ReadDir(reviewingRoot)
|
entries, _ := os.ReadDir(filepath.Join(root, "Project-1", "reviewing"))
|
||||||
if len(entries) > 0 {
|
if len(entries) > 0 {
|
||||||
t.Errorf("reviewing/ created despite 403: %d entries", len(entries))
|
t.Errorf("reviewing/ created despite 403: %d entries", len(entries))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ func ServeProfile(cfg config.Config, ring *LogRing, idx *archive.Index, w http.R
|
||||||
case "/", "":
|
case "/", "":
|
||||||
serveProfilePage(cfg, w, r)
|
serveProfilePage(cfg, w, r)
|
||||||
case "/access":
|
case "/access":
|
||||||
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, PrincipalFromContext(r), r.URL.Query().Get("path")))
|
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, PrincipalFromContext(r)))
|
||||||
case "/projects":
|
case "/projects":
|
||||||
serveProfileProjectsCreate(cfg, w, r)
|
serveProfileProjectsCreate(cfg, w, r)
|
||||||
case "/whoami":
|
case "/whoami":
|
||||||
|
|
@ -150,31 +150,6 @@ type AccessView struct {
|
||||||
CanCreateProject bool `json:"can_create_project"`
|
CanCreateProject bool `json:"can_create_project"`
|
||||||
Projects []ProjectInfo `json:"projects"`
|
Projects []ProjectInfo `json:"projects"`
|
||||||
AdminSubtrees []treeEntry `json:"admin_subtrees"`
|
AdminSubtrees []treeEntry `json:"admin_subtrees"`
|
||||||
|
|
||||||
// Path-scoped fields. Populated only when the caller passes
|
|
||||||
// ?path=<url-path> on the request. Empty when the global view
|
|
||||||
// (no ?path=) was requested, so the existing global-shape clients
|
|
||||||
// keep their wire format unchanged.
|
|
||||||
//
|
|
||||||
// PathVerbs is the canonical "rwcda" subset granted to the caller
|
|
||||||
// at the requested path under their CURRENT elevation state. A
|
|
||||||
// top-of-tool affordance (transmittal's Publish, tables' +Add row,
|
|
||||||
// browse's +New folder toolbar) reads this once on load and gates
|
|
||||||
// itself accordingly.
|
|
||||||
//
|
|
||||||
// PathIsAdmin reports whether the caller has subtree-admin
|
|
||||||
// authority at the requested path, again under current elevation.
|
|
||||||
// Distinct from "verbs include 'a'": admin authority is the WORM-
|
|
||||||
// bypass capability, not just .zddc edit access.
|
|
||||||
//
|
|
||||||
// PathCanElevateGrant is the verb set the caller would hold AT
|
|
||||||
// THIS PATH if they elevated — empty when elevation would change
|
|
||||||
// nothing (already elevated, or no admin grant on the chain).
|
|
||||||
// Drives toast offers like "Elevate to delete this file" without
|
|
||||||
// the client second-guessing the cascade.
|
|
||||||
PathVerbs string `json:"path_verbs,omitempty"`
|
|
||||||
PathIsAdmin bool `json:"path_is_admin,omitempty"`
|
|
||||||
PathCanElevateGrant string `json:"path_can_elevate_grant,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// enumerateAccess builds an AccessView for the given caller. Used by the
|
// enumerateAccess builds an AccessView for the given caller. Used by the
|
||||||
|
|
@ -183,13 +158,7 @@ type AccessView struct {
|
||||||
// view after first paint. The principal carries elevation: an un-elevated
|
// view after first paint. The principal carries elevation: an un-elevated
|
||||||
// admin reports IsSuperAdmin=false here, so the UI naturally renders the
|
// admin reports IsSuperAdmin=false here, so the UI naturally renders the
|
||||||
// non-elevated view (no admin scaffolds shown) until the user opts in.
|
// non-elevated view (no admin scaffolds shown) until the user opts in.
|
||||||
//
|
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal) AccessView {
|
||||||
// pathQuery is the optional ?path=<url-path> query value — when non-empty
|
|
||||||
// the path-scoped fields (PathVerbs, PathIsAdmin, PathCanElevateGrant) are
|
|
||||||
// populated so a single fetch answers both "what can I do globally" and
|
|
||||||
// "what can I do at this URL". An invalid or escape-attempting path is
|
|
||||||
// silently ignored (the global fields still return).
|
|
||||||
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal, pathQuery string) AccessView {
|
|
||||||
view := AccessView{
|
view := AccessView{
|
||||||
Email: p.Email,
|
Email: p.Email,
|
||||||
EmailHeader: cfg.EmailHeader,
|
EmailHeader: cfg.EmailHeader,
|
||||||
|
|
@ -210,50 +179,9 @@ func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Con
|
||||||
allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate)
|
allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate)
|
||||||
view.CanCreateProject = allowed
|
view.CanCreateProject = allowed
|
||||||
}
|
}
|
||||||
if pathQuery != "" {
|
|
||||||
populatePathScopedAccess(ctx, decider, cfg, p, pathQuery, &view)
|
|
||||||
}
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
// populatePathScopedAccess fills the PathVerbs / PathIsAdmin /
|
|
||||||
// PathCanElevateGrant fields by walking the cascade at pathQuery and
|
|
||||||
// running the decider for each verb under (1) the caller's actual
|
|
||||||
// elevation and (2) a hypothetical elevated principal. Path resolution
|
|
||||||
// mirrors serveProfileEffectivePolicy: must start with "/", must not
|
|
||||||
// escape ZDDC_ROOT. Validation failures leave the fields empty rather
|
|
||||||
// than 400ing — the global view is still useful, and the client can
|
|
||||||
// detect absence.
|
|
||||||
func populatePathScopedAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal, pathQuery string, view *AccessView) {
|
|
||||||
if !strings.HasPrefix(pathQuery, "/") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rel := strings.TrimPrefix(pathQuery, "/")
|
|
||||||
rel = strings.TrimSuffix(rel, "/")
|
|
||||||
absDir, ok := safeJoin(cfg.Root, rel)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chain, err := zddc.EffectivePolicy(cfg.Root, absDir)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, p, pathQuery)
|
|
||||||
view.PathVerbs = verbs.String()
|
|
||||||
view.PathIsAdmin = p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email)
|
|
||||||
// would_elevate_grant: only meaningful when (a) the caller isn't
|
|
||||||
// already elevated and (b) elevation would actually change the
|
|
||||||
// verb set. Avoid noise — an empty value tells the client there
|
|
||||||
// is nothing to offer.
|
|
||||||
if !p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email) {
|
|
||||||
elevatedP := zddc.Principal{Email: p.Email, Elevated: true}
|
|
||||||
ifElevated := policy.EffectiveVerbsFromChainP(ctx, decider, chain, elevatedP, pathQuery)
|
|
||||||
if ifElevated != verbs {
|
|
||||||
view.PathCanElevateGrant = ifElevated.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// enumerateAdminSubtrees lists every directory containing a .zddc that the
|
// enumerateAdminSubtrees lists every directory containing a .zddc that the
|
||||||
// caller can see as an admin (super-admin or subtree-admin). Every entry
|
// caller can see as an admin (super-admin or subtree-admin). Every entry
|
||||||
// is editable — subtree admins own their own .zddc. Returns empty for an
|
// is editable — subtree admins own their own .zddc. Returns empty for an
|
||||||
|
|
|
||||||
|
|
@ -487,115 +487,6 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestServeProfileAccessPathScoped — /.profile/access?path=<url> answers
|
|
||||||
// "what can the caller do at this URL" alongside the global view. Three
|
|
||||||
// flavors cover the cases the toast/menu gating cares about:
|
|
||||||
//
|
|
||||||
// - non-admin caller with explicit ACL grant: PathVerbs reflects the
|
|
||||||
// grant; PathIsAdmin=false; PathCanElevateGrant empty (elevation
|
|
||||||
// wouldn't change anything for a non-admin).
|
|
||||||
// - un-elevated admin: PathVerbs reflects the explicit grant (no
|
|
||||||
// admin bypass yet); PathIsAdmin=false; PathCanElevateGrant carries
|
|
||||||
// the full "rwcda" elevation would unlock.
|
|
||||||
// - elevated admin: PathVerbs="rwcda" (admin bypass active);
|
|
||||||
// PathIsAdmin=true; PathCanElevateGrant empty (nothing to upgrade).
|
|
||||||
func TestServeProfileAccessPathScoped(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
// Root admins list — sudo authority for admin@example.com (when
|
|
||||||
// elevated). Permissions grant alice rw at the project level.
|
|
||||||
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(`admins:
|
|
||||||
- admin@example.com
|
|
||||||
acl:
|
|
||||||
permissions:
|
|
||||||
"alice@example.com": rw
|
|
||||||
`), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(root)
|
|
||||||
zddc.InvalidateScanCache()
|
|
||||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
||||||
ring := NewLogRing(50)
|
|
||||||
|
|
||||||
fetch := func(email string, elevated bool) AccessView {
|
|
||||||
t.Helper()
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
ServeProfile(cfg, ring, nil, rec,
|
|
||||||
requestAsUserMaybeElevated(http.MethodGet, "/.profile/access?path=/Proj/", email, elevated))
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("email=%q elevated=%v status=%d body=%s", email, elevated, rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
var v AccessView
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
|
|
||||||
t.Fatalf("decode email=%q: %v", email, err)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-admin caller with explicit grant: verbs reflect the ACL,
|
|
||||||
// no admin status, no elevation offer.
|
|
||||||
alice := fetch("alice@example.com", false)
|
|
||||||
if alice.PathVerbs != "rw" {
|
|
||||||
t.Errorf("alice PathVerbs = %q, want rw", alice.PathVerbs)
|
|
||||||
}
|
|
||||||
if alice.PathIsAdmin {
|
|
||||||
t.Errorf("alice PathIsAdmin = true, want false")
|
|
||||||
}
|
|
||||||
if alice.PathCanElevateGrant != "" {
|
|
||||||
t.Errorf("alice PathCanElevateGrant = %q, want empty (no admin grant on chain)", alice.PathCanElevateGrant)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Un-elevated admin: bypass not active, so explicit verbs are
|
|
||||||
// whatever ACL granted (here: nothing — admin@ has no permissions
|
|
||||||
// entry, only an admins: entry). PathCanElevateGrant tells the
|
|
||||||
// client "elevation would unlock rwcda".
|
|
||||||
adminUn := fetch("admin@example.com", false)
|
|
||||||
if adminUn.PathVerbs != "" {
|
|
||||||
t.Errorf("un-elevated admin PathVerbs = %q, want empty (no explicit grant)", adminUn.PathVerbs)
|
|
||||||
}
|
|
||||||
if adminUn.PathIsAdmin {
|
|
||||||
t.Errorf("un-elevated admin PathIsAdmin = true, want false")
|
|
||||||
}
|
|
||||||
if adminUn.PathCanElevateGrant != "rwcda" {
|
|
||||||
t.Errorf("un-elevated admin PathCanElevateGrant = %q, want rwcda", adminUn.PathCanElevateGrant)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Elevated admin: full bypass — verbs rwcda, PathIsAdmin true,
|
|
||||||
// no elevation offer (already elevated).
|
|
||||||
adminEl := fetch("admin@example.com", true)
|
|
||||||
if adminEl.PathVerbs != "rwcda" {
|
|
||||||
t.Errorf("elevated admin PathVerbs = %q, want rwcda", adminEl.PathVerbs)
|
|
||||||
}
|
|
||||||
if !adminEl.PathIsAdmin {
|
|
||||||
t.Errorf("elevated admin PathIsAdmin = false, want true")
|
|
||||||
}
|
|
||||||
if adminEl.PathCanElevateGrant != "" {
|
|
||||||
t.Errorf("elevated admin PathCanElevateGrant = %q, want empty (already elevated)", adminEl.PathCanElevateGrant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServeProfileAccessNoPathQuery — without ?path=, the global view
|
|
||||||
// works unchanged: path-scoped fields are absent, every existing
|
|
||||||
// global field is populated.
|
|
||||||
func TestServeProfileAccessNoPathQuery(t *testing.T) {
|
|
||||||
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/access", "alice@example.com"))
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status=%d", rec.Code)
|
|
||||||
}
|
|
||||||
var v AccessView
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
|
|
||||||
t.Fatalf("decode: %v", err)
|
|
||||||
}
|
|
||||||
if v.PathVerbs != "" || v.PathIsAdmin || v.PathCanElevateGrant != "" {
|
|
||||||
t.Errorf("global view should not include path-scoped fields; got PathVerbs=%q PathIsAdmin=%v PathCanElevateGrant=%q",
|
|
||||||
v.PathVerbs, v.PathIsAdmin, v.PathCanElevateGrant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServeProfileEffectivePolicy: admin queries the cascade tracer for a
|
// TestServeProfileEffectivePolicy: admin queries the cascade tracer for a
|
||||||
// (path, email) tuple and gets back the resolved chain plus the decision.
|
// (path, email) tuple and gets back the resolved chain plus the decision.
|
||||||
// The fixture mirrors the worked-example layout from zddc/README.md (a
|
// The fixture mirrors the worked-example layout from zddc/README.md (a
|
||||||
|
|
|
||||||
|
|
@ -386,7 +386,7 @@ func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *
|
||||||
slog.Warn("table: policy error", "path", req.Dir, "err", err)
|
slog.Warn("table: policy error", "path", req.Dir, "err", err)
|
||||||
}
|
}
|
||||||
if allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, r.URL.Path, policy.ActionRead); !allowed {
|
if allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, r.URL.Path, policy.ActionRead); !allowed {
|
||||||
writeForbidden(w, policy.ActionRead)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1515,7 +1515,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.20-dev · 2026-05-20 20:09:54 · 703449a-dirty</span></span>
|
<span class="build-timestamp">v0.0.19</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -4219,17 +4219,6 @@ body.is-elevated::after {
|
||||||
const col = colAt(c);
|
const col = colAt(c);
|
||||||
if (!row || !col) return;
|
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);
|
const propSchema = propertySchemaFor(col);
|
||||||
|
|
||||||
// Complex-type cells (nested object, generic array, oneOf)
|
// Complex-type cells (nested object, generic array, oneOf)
|
||||||
|
|
@ -5181,17 +5170,7 @@ body.is-elevated::after {
|
||||||
// form-mode and never produce drafts here, so drafts only
|
// form-mode and never produce drafts here, so drafts only
|
||||||
// contain primitive / string-array values that are safe to
|
// contain primitive / string-array values that are safe to
|
||||||
// overwrite the corresponding top-level field.
|
// 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) {
|
function rowFromState(rowId) {
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,13 @@
|
||||||
// doesn't carry:
|
// doesn't carry:
|
||||||
//
|
//
|
||||||
// - SSR rows get `name: <party>` so the table renderer has a column
|
// - SSR rows get `name: <party>` so the table renderer has a column
|
||||||
// to sort on and the form edit pre-fills the party name. (Identity
|
// to sort on and the form edit pre-fills the party name.
|
||||||
// of an SSR row is the party folder name, so the field is named
|
// - MDL / RSK rollup rows get `party: <party>` so the rollup table
|
||||||
// plainly rather than sigil-prefixed.)
|
// can show which package each row came from.
|
||||||
// - 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
|
// Both fields are stripped before write-back (SSR via serveFormCreateSSR
|
||||||
// strip; MDL/RSK rollup writes go through the generic serveFormUpdate,
|
// 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
|
// false` in the underlying schema — so the client must strip it on
|
||||||
// submit, which the tables/form JS already does for path-derived
|
// submit, which the tables/form JS already does for path-derived
|
||||||
// fields).
|
// fields).
|
||||||
|
|
@ -85,7 +79,7 @@ func ServeVirtualViewRow(w http.ResponseWriter, r *http.Request, vv zddc.Virtual
|
||||||
case zddc.VirtualViewSSRRow:
|
case zddc.VirtualViewSSRRow:
|
||||||
data["name"] = vv.Party
|
data["name"] = vv.Party
|
||||||
case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow:
|
case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow:
|
||||||
data["$party"] = vv.Party
|
data["party"] = vv.Party
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := yaml.Marshal(data)
|
out, err := yaml.Marshal(data)
|
||||||
|
|
|
||||||
|
|
@ -60,30 +60,5 @@ type FileInfo struct {
|
||||||
// false-or-unknown and gate writes accordingly. Read-only-by-
|
// false-or-unknown and gate writes accordingly. Read-only-by-
|
||||||
// default is the safer client-side fallback if the server forgets
|
// default is the safer client-side fallback if the server forgets
|
||||||
// to populate it.
|
// to populate it.
|
||||||
//
|
|
||||||
// Superseded by Verbs (which carries the full verb set); kept
|
|
||||||
// alongside it for the transition window so existing clients
|
|
||||||
// reading writable: don't break. New clients should read Verbs and
|
|
||||||
// check for 'w'.
|
|
||||||
Writable bool `json:"writable,omitempty"`
|
Writable bool `json:"writable,omitempty"`
|
||||||
|
|
||||||
// Verbs is the canonical "rwcda" subset granted to the calling
|
|
||||||
// principal at this entry's URL. Computed by running the policy
|
|
||||||
// decider for each of the five actions and unioning the allowed
|
|
||||||
// bits, with the same active-admin bypass that Writable uses.
|
|
||||||
//
|
|
||||||
// Semantics per entry kind:
|
|
||||||
// - file: verbs from the parent directory's chain (files have no
|
|
||||||
// .zddc of their own; they inherit). Same chain Writable uses.
|
|
||||||
// - directory: verbs from the subdirectory's OWN chain, so a
|
|
||||||
// fenced/extended .zddc inside it shows through. The client
|
|
||||||
// interprets per-kind: `w` on a dir = "I can rename this
|
|
||||||
// folder", `c` on a dir = N/A at this URL (use path-scoped
|
|
||||||
// /.profile/access?path= for "can I create inside this dir").
|
|
||||||
//
|
|
||||||
// omitempty: empty set ("") is the explicit-deny case; absence
|
|
||||||
// means the server didn't populate it. Clients should treat both
|
|
||||||
// as "no permissions known" and fall back to a server round-trip
|
|
||||||
// (or just disable affordances) rather than assuming any grant.
|
|
||||||
Verbs string `json:"verbs,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -395,25 +395,6 @@ func AllowActionFromChainP(ctx context.Context, d Decider, chain zddc.PolicyChai
|
||||||
return d.Allow(ctx, in)
|
return d.Allow(ctx, in)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveVerbsFromChainP returns the verb set the principal effectively
|
|
||||||
// holds at path under chain. Routes each verb through the decider so an
|
|
||||||
// external OPA's overrides surface in the result; with the InternalDecider
|
|
||||||
// the answer is the cascade's effective grant plus WORM composition plus
|
|
||||||
// the active-admin bypass. Output is the same wire shape the client
|
|
||||||
// receives in listing entry verbs / /.profile/access?path= responses.
|
|
||||||
func EffectiveVerbsFromChainP(ctx context.Context, d Decider, chain zddc.PolicyChain, p zddc.Principal, path string) zddc.VerbSet {
|
|
||||||
if p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email) {
|
|
||||||
return zddc.VerbAll
|
|
||||||
}
|
|
||||||
var verbs zddc.VerbSet
|
|
||||||
for _, action := range []string{ActionRead, ActionWrite, ActionCreate, ActionDelete, ActionAdmin} {
|
|
||||||
if allowed, _ := AllowActionFromChainP(ctx, d, chain, p, path, action); allowed {
|
|
||||||
verbs |= actionVerb(action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return verbs
|
|
||||||
}
|
|
||||||
|
|
||||||
// cachingDecider wraps another Decider with a small per-decision cache.
|
// cachingDecider wraps another Decider with a small per-decision cache.
|
||||||
// Designed for the external-OPA hot path: a single .archive listing or
|
// Designed for the external-OPA hot path: a single .archive listing or
|
||||||
// directory enumeration can hit the same (email, dir-policy) tuple
|
// directory enumeration can hit the same (email, dir-policy) tuple
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ acl:
|
||||||
|
|
||||||
# ── Standard roles ─────────────────────────────────────────────────────────
|
# ── Standard roles ─────────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# Three roles ship empty (no members) — a fresh deployment grants
|
# Two roles ship empty (no members) — a fresh deployment grants
|
||||||
# nothing until an operator populates them. They're referenced by the
|
# nothing until an operator populates them. They're referenced by the
|
||||||
# project-scoped grants in paths: below.
|
# project-scoped grants in paths: below.
|
||||||
#
|
#
|
||||||
|
|
@ -33,38 +33,24 @@ acl:
|
||||||
# `reset: true` on the role at that level — ancestor definitions above
|
# `reset: true` on the role at that level — ancestor definitions above
|
||||||
# the reset are then excluded.
|
# the reset are then excluded.
|
||||||
#
|
#
|
||||||
# document_controller — the people who file into
|
# document_controller — the people who file into archive/<party>/
|
||||||
# archive/<party>/received/ and issued/ (WORM zones). They get
|
# received/ and issued/ (WORM zones). They get read+write-once-
|
||||||
# read+write-once-create there (via the worm: lists below) and
|
# create there (via the worm: lists below) and read/write
|
||||||
# read/write elsewhere in a project, plus subtree-admin of the
|
# elsewhere in a project, plus subtree-admin of working/ and
|
||||||
# per-party working/ + staging/ + reviewing/ so they can stand up
|
# staging/ so they can stand up new top-level folders and manage
|
||||||
# and manage drafting/transmittal/review folders. They are NOT
|
# user/staging subtrees. They are NOT subtree-admin of archive/,
|
||||||
# subtree-admin of archive/<party>/, so the WORM constraint still
|
# so the WORM constraint still binds them in received/issued.
|
||||||
# binds them in received/issued. Plan-Review approval is part of
|
|
||||||
# this role by design — there is no separate `approver` role;
|
|
||||||
# two-person sign-off, when needed, is expressed via per-folder
|
|
||||||
# `.zddc` overrides rather than baked-in roles.
|
|
||||||
#
|
#
|
||||||
# project_team — everyone working on a project. Read-only across
|
# project_team — everyone working on a project. Read-only across
|
||||||
# the project. Their own archive/<party>/working/<email>/ home and
|
# the project. Their own working/<email>/ home and anything they
|
||||||
# anything they create under incoming/ get a creator-owned auto-
|
# create under incoming/ get a creator-owned auto-own .zddc
|
||||||
# own .zddc (rwcda) which wins via deepest-match, so "read-only
|
# (rwcda) which wins via deepest-match, so "read-only except
|
||||||
# except what I own" falls out of the cascade with no special rule.
|
# what I own" falls out of the cascade with no special rule.
|
||||||
#
|
|
||||||
# observer — pure read-only across the project. Like project_team
|
|
||||||
# but with no auto-own home: an observer who somehow created a
|
|
||||||
# working/<email>/ would still own it via auto-own (the mechanism
|
|
||||||
# is path-keyed, not role-keyed), but since observer lacks `c`
|
|
||||||
# anywhere, the situation doesn't arise in practice. Intended for
|
|
||||||
# auditors, regulators, and external read-only viewers who must
|
|
||||||
# not contribute content.
|
|
||||||
roles:
|
roles:
|
||||||
document_controller:
|
document_controller:
|
||||||
members: []
|
members: []
|
||||||
project_team:
|
project_team:
|
||||||
members: []
|
members: []
|
||||||
observer:
|
|
||||||
members: []
|
|
||||||
|
|
||||||
# Universal tool baseline. archive (record browser), browse (file
|
# Universal tool baseline. archive (record browser), browse (file
|
||||||
# tree, hosts the in-place markdown editor), and landing (project
|
# tree, hosts the in-place markdown editor), and landing (project
|
||||||
|
|
@ -103,32 +89,17 @@ available_tools: [archive, browse, landing]
|
||||||
#
|
#
|
||||||
# ── Canonical project structure ────────────────────────────────────────────
|
# ── Canonical project structure ────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# Every ZDDC project lives at a top-level directory. Under it
|
# Every ZDDC project lives at a top-level directory. Under it the
|
||||||
# `archive/` is the ONLY real top-level folder; it contains a folder
|
# convention is four canonical folders: archive (formal record),
|
||||||
# per party. Everything party-scoped (the SSR row, MDL/RSK rollups,
|
# working (in-progress workspace), staging (outbound prep), reviewing
|
||||||
# WORM received/issued, the incoming drop zone, and the in-flight
|
# (Plan-Review-managed draft workspaces). Under archive/<party>/ the
|
||||||
# lifecycle slots working/staging/reviewing) lives uniformly under
|
# convention is four more: mdl (deliverables list), incoming (counterparty
|
||||||
# archive/<party>/.
|
# drop zone), received (immutable submittals), issued (immutable responses).
|
||||||
#
|
#
|
||||||
# Six top-level virtuals sit beside archive/ as resolver views:
|
# All of this is expressed via the recursive paths: schema. None of
|
||||||
#
|
# the directories need to exist on disk — the cascade walker resolves
|
||||||
# ssr mdl rsk tables rollups across parties
|
# behaviour from this declaration, so a fresh project lands on
|
||||||
# (with a synthesized $party column)
|
# usable empty views at every well-known URL.
|
||||||
# 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
|
# Operators override any of this by mirroring the structure in an
|
||||||
# on-disk .zddc and changing what they need; on-disk values win.
|
# on-disk .zddc and changing what they need; on-disk values win.
|
||||||
|
|
@ -136,56 +107,27 @@ available_tools: [archive, browse, landing]
|
||||||
paths:
|
paths:
|
||||||
# First segment under root is the project name; "*" matches any.
|
# First segment under root is the project name; "*" matches any.
|
||||||
"*":
|
"*":
|
||||||
# Project-scoped baseline ACL. project_team and observer get read
|
# Project-scoped baseline ACL. project_team gets read across the
|
||||||
# across the project; document_controller gets read + overwrite-
|
# project; document_controller gets read + overwrite-existing
|
||||||
# existing (so people can ask them to fix a stuck file). None of
|
# (so people can ask them to fix a stuck file). Neither gets
|
||||||
# the three gets `c` (create) at this level — that's granted only
|
# `c` (create) at this level — that's granted only at the
|
||||||
# at the specific spots below (archive/, working/, staging/), so
|
# specific spots below (archive/, working/, staging/), so the
|
||||||
# the doc controller can't make arbitrary folders. Grants here cap
|
# doc controller can't make arbitrary folders. Grants here cap
|
||||||
# at deeper levels per deepest-match-wins, except where a deeper
|
# at deeper levels per deepest-match-wins, except where a deeper
|
||||||
# .zddc restates a fuller grant for the same principal.
|
# .zddc restates a fuller grant for the same principal.
|
||||||
acl:
|
acl:
|
||||||
permissions:
|
permissions:
|
||||||
project_team: r
|
project_team: r
|
||||||
observer: r
|
|
||||||
document_controller: rw
|
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:
|
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:
|
archive:
|
||||||
default_tool: archive
|
default_tool: archive
|
||||||
# The doc controller can create party subfolders here
|
# The doc controller can create party subfolders here
|
||||||
|
|
@ -203,20 +145,12 @@ paths:
|
||||||
# to received/issued). That lets them set up the
|
# to received/issued). That lets them set up the
|
||||||
# counterparty's own .zddc afterward.
|
# counterparty's own .zddc afterward.
|
||||||
auto_own: true
|
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
|
# SSR record: the party folder's ssr.yaml carries this
|
||||||
# party's vendor / contract / status data. Scoped by
|
# party's vendor / contract / status data. Scoped by
|
||||||
# filename pattern so the lock on `kind` only applies to
|
# filename pattern so the lock on `kind` only applies to
|
||||||
# ssr.yaml — the mdl/, rsk/, received/, working/,
|
# ssr.yaml — the mdl/, rsk/, received/ subfolders are
|
||||||
# staging/, reviewing/ subfolders are untouched. No
|
# untouched. No filename_format because identity is the
|
||||||
# filename_format because identity is the party folder
|
# party folder name, not a composed tracking number.
|
||||||
# name, not a composed tracking number.
|
|
||||||
records:
|
records:
|
||||||
"ssr.yaml":
|
"ssr.yaml":
|
||||||
field_defaults:
|
field_defaults:
|
||||||
|
|
@ -317,47 +251,74 @@ paths:
|
||||||
issued:
|
issued:
|
||||||
default_tool: archive
|
default_tool: archive
|
||||||
worm: [document_controller]
|
worm: [document_controller]
|
||||||
# ── In-flight lifecycle slots (NEW — nested per-party) ────
|
working:
|
||||||
#
|
default_tool: browse
|
||||||
# working/staging/reviewing now live inside each party
|
available_tools: [browse, classifier]
|
||||||
# folder instead of at the project root. The project-
|
# working/ auto-owns the first creator + the per-user homes
|
||||||
# level <project>/{working,staging,reviewing} virtuals
|
# below.
|
||||||
# (declared above) are folder-nav views over these
|
auto_own: true
|
||||||
# canonical per-party slots.
|
drop_target: true
|
||||||
working:
|
# Doc controller is subtree-admin of working/ — full create
|
||||||
default_tool: browse
|
# + manage, including taking over a fenced per-user home if a
|
||||||
available_tools: [browse, classifier]
|
# user leaves. (Scoped here, not at the project root, so the
|
||||||
# working/ auto-owns the first creator + the per-user
|
# WORM constraint in archive/<party>/received|issued still
|
||||||
# homes below.
|
# binds them.)
|
||||||
auto_own: true
|
admins: [document_controller]
|
||||||
drop_target: true
|
paths:
|
||||||
paths:
|
"*": # per-user home dir
|
||||||
"*": # per-user home dir, fenced
|
default_tool: browse
|
||||||
default_tool: browse
|
available_tools: [browse, classifier]
|
||||||
available_tools: [browse, classifier]
|
auto_own: true
|
||||||
auto_own: true
|
# Per-user home is private by default: the generated
|
||||||
# Per-user home is private by default: the generated
|
# auto-own .zddc carries inherit:false so ancestor ACL
|
||||||
# auto-own .zddc carries inherit:false so ancestor ACL
|
# grants don't reach inside. The user can edit the file
|
||||||
# grants don't reach inside. The user can edit the file
|
# to grant collaborators access.
|
||||||
# to grant collaborators access.
|
auto_own_fenced: true
|
||||||
auto_own_fenced: true
|
drop_target: true
|
||||||
drop_target: true
|
staging:
|
||||||
staging:
|
default_tool: transmittal
|
||||||
default_tool: transmittal
|
available_tools: [transmittal, classifier]
|
||||||
available_tools: [transmittal, classifier]
|
auto_own: true
|
||||||
auto_own: true
|
drop_target: true
|
||||||
drop_target: true
|
# Doc controller is subtree-admin of staging/ too — same
|
||||||
reviewing:
|
# rationale as working/.
|
||||||
default_tool: browse
|
admins: [document_controller]
|
||||||
available_tools: [browse]
|
reviewing:
|
||||||
# reviewing/ is the doc-controller's draft-workspace
|
default_tool: browse
|
||||||
# area inside this party folder. The "Plan Review"
|
available_tools: [browse]
|
||||||
# composite endpoint scaffolds a physical folder here
|
# reviewing/ is the doc-controller's draft-workspace area. The
|
||||||
# for each submittal under review, with a .zddc
|
# "Plan Review" composite endpoint (see on_plan_review at project
|
||||||
# carrying received_path back to the canonical
|
# level) scaffolds a physical folder here for each submittal
|
||||||
# submittal in received/. Subtree-admin (inherited
|
# under review, with a .zddc carrying received_path back to the
|
||||||
# from the party-level admins:) so the doc
|
# canonical submittal in received/. Subtree-admin so the doc
|
||||||
# controller can author per-folder .zddc files
|
# controller can author per-folder .zddc files (originator ACL,
|
||||||
# (originator ACL, planned_date).
|
# planned_date).
|
||||||
auto_own: true
|
auto_own: true
|
||||||
drop_target: 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
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
|
||||||
|
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
seg := strings.ToLower(parts[1])
|
seg := strings.ToLower(parts[1])
|
||||||
if seg == "archive" {
|
if seg == "archive" || seg == "working" || seg == "staging" {
|
||||||
if err := resolveAt(1, seg); err != nil {
|
if err := resolveAt(1, seg); err != nil {
|
||||||
return target, err
|
return target, err
|
||||||
}
|
}
|
||||||
|
|
@ -60,8 +60,7 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
|
||||||
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
||||||
seg := strings.ToLower(parts[3])
|
seg := strings.ToLower(parts[3])
|
||||||
switch seg {
|
switch seg {
|
||||||
case "mdl", "rsk", "incoming", "received", "issued",
|
case "mdl", "incoming", "received", "issued":
|
||||||
"working", "staging", "reviewing":
|
|
||||||
if err := resolveAt(3, seg); err != nil {
|
if err := resolveAt(3, seg); err != nil {
|
||||||
return target, err
|
return target, err
|
||||||
}
|
}
|
||||||
|
|
@ -71,26 +70,26 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureCanonicalAncestors walks from fsRoot down to filepath.Dir(target),
|
// EnsureCanonicalAncestors walks from fsRoot down to filepath.Dir(target),
|
||||||
// creating any missing canonical-folder ancestor with MkdirAll(perm).
|
// creating any missing canonical-folder ancestor with MkdirAll(perm). For
|
||||||
// For freshly-created auto-own ancestors (archive/<party>/, and the per-
|
// freshly-created auto-own ancestors (working/, staging/, or
|
||||||
// party lifecycle slots {working,staging,reviewing,incoming}), it also
|
// archive/<party>/incoming/), it also writes a creator-owned .zddc using
|
||||||
// writes a creator-owned .zddc using principalEmail (skipped if
|
// principalEmail (skipped if principalEmail is empty).
|
||||||
// principalEmail is empty).
|
|
||||||
//
|
//
|
||||||
// Returns the resolved version of target with on-disk casing substituted
|
// Returns the resolved version of target with on-disk casing substituted
|
||||||
// for any canonical ancestor whose disk variant differs from the requested
|
// for any canonical ancestor whose disk variant differs from the requested
|
||||||
// casing — so a pre-existing Archive/ is reused rather than shadowed by a
|
// casing — so a pre-existing Working/ is reused rather than shadowed by a
|
||||||
// new archive/ sibling. The basename of target is never altered.
|
// new working/ sibling. The basename of target is never altered.
|
||||||
//
|
//
|
||||||
// Canonical positions, relative to fsRoot:
|
// Canonical positions, relative to fsRoot:
|
||||||
//
|
//
|
||||||
// - <project>/archive (the only physical project-root canonical;
|
// - <project>/<canonical-root> where <canonical-root> ∈
|
||||||
// working/staging/reviewing/ssr/mdl/rsk at project root are virtual
|
// {archive, working, staging}
|
||||||
// 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
|
// - <project>/archive/<party>/<canonical-party> where
|
||||||
// <canonical-party> ∈ {mdl, rsk, incoming, received, issued,
|
// <canonical-party> ∈ {mdl, incoming, received, issued}
|
||||||
// working, staging, reviewing}
|
//
|
||||||
|
// "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).
|
||||||
//
|
//
|
||||||
// fsRoot and target must be absolute filesystem paths under the same
|
// fsRoot and target must be absolute filesystem paths under the same
|
||||||
// volume; target may not yet exist on disk.
|
// volume; target may not yet exist on disk.
|
||||||
|
|
@ -110,15 +109,9 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
return target, nil
|
return target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject writes targeting top-level virtual aggregators —
|
// Reject writes under reviewing/ — virtual route.
|
||||||
// <project>/{ssr,mdl,rsk,working,staging,reviewing}/... — these
|
if len(parts) >= 2 && strings.EqualFold(parts[1], "reviewing") {
|
||||||
// resolve through ResolveVirtualView, not as physical paths. A
|
return target, fmt.Errorf("reviewing/ is virtual and not writable")
|
||||||
// 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))
|
resolvedSegs := make([]string, len(parts))
|
||||||
|
|
@ -162,23 +155,21 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
// Walk depth 1 (project) → deeper levels, resolving + tracking as we go.
|
// Walk depth 1 (project) → deeper levels, resolving + tracking as we go.
|
||||||
// Depth 0 is the project segment; not a canonical name.
|
// Depth 0 is the project segment; not a canonical name.
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
// Depth 1 candidate: archive (only physical project-root canonical).
|
// Depth 1 candidate: archive / working / staging.
|
||||||
seg := strings.ToLower(parts[1])
|
seg := strings.ToLower(parts[1])
|
||||||
if seg == "archive" {
|
if seg == "archive" || seg == "working" || seg == "staging" {
|
||||||
if err := resolveAt(1, seg); err != nil {
|
if err := resolveAt(1, seg); err != nil {
|
||||||
return target, err
|
return target, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Depth 3 candidate (archive/<party>/<canonical-party>): the eight
|
// Depth 3 candidate (archive/<party>/<canonical-party>): mdl / incoming /
|
||||||
// physical per-party slots. Only meaningful when depth 1 is
|
// received / issued. Only meaningful when depth 1 is "archive".
|
||||||
// "archive".
|
|
||||||
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
||||||
seg := strings.ToLower(parts[3])
|
seg := strings.ToLower(parts[3])
|
||||||
switch seg {
|
switch seg {
|
||||||
case "mdl", "rsk", "incoming", "received", "issued",
|
case "mdl", "incoming", "received", "issued":
|
||||||
"working", "staging", "reviewing":
|
|
||||||
if err := resolveAt(3, seg); err != nil {
|
if err := resolveAt(3, seg); err != nil {
|
||||||
return target, err
|
return target, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,7 @@ import (
|
||||||
|
|
||||||
func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
|
func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
// Per-user homes now live under archive/<party>/working/<email>/
|
target := filepath.Join(root, "Proj", "working", "alice@x.com", "notes.md")
|
||||||
// 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)
|
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -23,10 +19,8 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
|
||||||
t.Errorf("resolved=%q, target=%q (no case variant exists, should be identical)", resolved, target)
|
t.Errorf("resolved=%q, target=%q (no case variant exists, should be identical)", resolved, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
// working/ is now created with auto-own .zddc (unfenced — party
|
// working/ is now created with auto-own .zddc.
|
||||||
// admins still cascade through, only the per-user home below is
|
autoZ := filepath.Join(root, "Proj", "working", ".zddc")
|
||||||
// fenced).
|
|
||||||
autoZ := filepath.Join(root, "Proj", "archive", "ACME", "working", ".zddc")
|
|
||||||
data, err := os.ReadFile(autoZ)
|
data, err := os.ReadFile(autoZ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("auto-own .zddc not written at working/: %v", err)
|
t.Fatalf("auto-own .zddc not written at working/: %v", err)
|
||||||
|
|
@ -38,15 +32,12 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
|
||||||
if !strings.Contains(body, "created_by: alice@x.com") {
|
if !strings.Contains(body, "created_by: alice@x.com") {
|
||||||
t.Errorf("created_by missing: %s", body)
|
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
|
// alice@x.com/ subfolder gets a FENCED auto-own .zddc — private by
|
||||||
// default so other users can't read alice's drafts via ancestor
|
// default so other users can't read alice's drafts via ancestor
|
||||||
// cascade. alice can edit the file later to add collaborators.
|
// cascade. alice can edit the file later to add collaborators.
|
||||||
homeZddc := filepath.Join(root, "Proj", "archive", "ACME", "working", "alice@x.com", ".zddc")
|
homeZddc := filepath.Join(root, "Proj", "working", "alice@x.com", ".zddc")
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working", "alice@x.com")); err != nil {
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "alice@x.com")); err != nil {
|
||||||
t.Errorf("subfolder not created: %v", err)
|
t.Errorf("subfolder not created: %v", err)
|
||||||
}
|
}
|
||||||
homeData, err := os.ReadFile(homeZddc)
|
homeData, err := os.ReadFile(homeZddc)
|
||||||
|
|
@ -67,52 +58,47 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
|
||||||
// under working/ get the fence.
|
// under working/ get the fence.
|
||||||
func TestEnsureCanonicalAncestors_StagingChildNotFenced(t *testing.T) {
|
func TestEnsureCanonicalAncestors_StagingChildNotFenced(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
target := filepath.Join(root, "Proj", "archive", "ACME", "staging",
|
target := filepath.Join(root, "Proj", "staging",
|
||||||
"2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", "doc.pdf")
|
"2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", "doc.pdf")
|
||||||
if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil {
|
if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil {
|
||||||
t.Fatalf("ensure: %v", err)
|
t.Fatalf("ensure: %v", err)
|
||||||
}
|
}
|
||||||
// staging/<folder>/.zddc should not exist (only the parent staging/
|
// staging/<folder>/.zddc should not exist (only the parent staging/
|
||||||
// gets an auto-own; the date-named child is plain).
|
// gets an auto-own; the date-named child is plain).
|
||||||
childZddc := filepath.Join(root, "Proj", "archive", "ACME", "staging",
|
childZddc := filepath.Join(root, "Proj", "staging",
|
||||||
"2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", ".zddc")
|
"2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", ".zddc")
|
||||||
if _, err := os.Stat(childZddc); !os.IsNotExist(err) {
|
if _, err := os.Stat(childZddc); !os.IsNotExist(err) {
|
||||||
t.Errorf("staging child should NOT have auto-own .zddc; got err=%v", 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) {
|
func TestEnsureCanonicalAncestors_CaseFoldReuse(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
// Pre-create Archive/ (PascalCase) — case-fold reuse applies to
|
// Pre-create Working/ (PascalCase).
|
||||||
// the canonical project-root slot.
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
target := filepath.Join(root, "Proj", "archive", "ACME", "working", "foo.md")
|
target := filepath.Join(root, "Proj", "working", "foo.md")
|
||||||
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ensure: %v", err)
|
t.Fatalf("ensure: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolved path uses on-disk Archive/ casing.
|
// Resolved path uses on-disk Working/ casing.
|
||||||
want := filepath.Join(root, "Proj", "Archive", "ACME", "working", "foo.md")
|
want := filepath.Join(root, "Proj", "Working", "foo.md")
|
||||||
if resolved != want {
|
if resolved != want {
|
||||||
t.Errorf("resolved=%q, want %q", resolved, want)
|
t.Errorf("resolved=%q, want %q", resolved, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No new lowercase archive/ sibling.
|
// No new working/ sibling.
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive")); !os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); !os.IsNotExist(err) {
|
||||||
t.Errorf("lowercase sibling should not exist; got err=%v", err)
|
t.Errorf("lowercase sibling should not exist; got err=%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archive/ already existed — no auto-own .zddc was retroactively written.
|
// Working/ already existed before our call — no auto-own .zddc was
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "Archive", ".zddc")); !os.IsNotExist(err) {
|
// retroactively written.
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "Proj", "Working", ".zddc")); !os.IsNotExist(err) {
|
||||||
t.Errorf("auto-own .zddc should not be written to a pre-existing folder; got err=%v", err)
|
t.Errorf("auto-own .zddc should not be written to a pre-existing folder; got err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -182,35 +168,30 @@ func TestEnsureCanonicalAncestors_WormFoldersNoAutoOwn(t *testing.T) {
|
||||||
|
|
||||||
func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) {
|
func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
target := filepath.Join(root, "Proj", "archive", "ACME", "working", "anon.md")
|
target := filepath.Join(root, "Proj", "working", "anon.md")
|
||||||
|
|
||||||
_, err := EnsureCanonicalAncestors(root, target, "" /* no email */, 0o755)
|
_, err := EnsureCanonicalAncestors(root, target, "" /* no email */, 0o755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ensure: %v", err)
|
t.Fatalf("ensure: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working")); err != nil {
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); err != nil {
|
||||||
t.Errorf("working/ not created: %v", err)
|
t.Errorf("working/ not created: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working", ".zddc")); !os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) {
|
||||||
t.Errorf("auto-own .zddc must not be written without principalEmail; got err=%v", err)
|
t.Errorf("auto-own .zddc must not be written without principalEmail; got err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project-root virtual aggregator names are rejected — a write
|
func TestEnsureCanonicalAncestors_RejectsReviewing(t *testing.T) {
|
||||||
// targeting <project>/working/<...> bypasses the virtual resolver
|
|
||||||
// and must not materialise on disk.
|
|
||||||
func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} {
|
target := filepath.Join(root, "Proj", "reviewing", "x.md")
|
||||||
target := filepath.Join(root, "Proj", slot, "x.md")
|
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
||||||
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
if err == nil {
|
||||||
if err == nil {
|
t.Errorf("expected error for write under reviewing/, got nil")
|
||||||
t.Errorf("%s: expected error for write under <project>/%s/, got nil", slot, slot)
|
}
|
||||||
}
|
if _, err := os.Stat(filepath.Join(root, "Proj", "reviewing")); !os.IsNotExist(err) {
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", slot)); !os.IsNotExist(err) {
|
t.Errorf("reviewing/ must NOT be created on disk; got err=%v", err)
|
||||||
t.Errorf("%s: <project>/%s/ must NOT be created on disk; got err=%v", slot, slot, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,18 @@ type Role struct {
|
||||||
Reset bool `yaml:"reset,omitempty" json:"reset,omitempty"`
|
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
|
// ConvertMetadata supplies per-project template variables for the
|
||||||
// server-side MD→{docx,html,pdf} conversion endpoint. The handler
|
// server-side MD→{docx,html,pdf} conversion endpoint. The handler
|
||||||
// resolves the effective set by walking the .zddc cascade leaf→root
|
// resolves the effective set by walking the .zddc cascade leaf→root
|
||||||
|
|
@ -325,6 +337,12 @@ type ZddcFile struct {
|
||||||
PlannedReviewDate string `yaml:"planned_review_date,omitempty" json:"planned_review_date,omitempty"`
|
PlannedReviewDate string `yaml:"planned_review_date,omitempty" json:"planned_review_date,omitempty"`
|
||||||
PlannedResponseDate string `yaml:"planned_response_date,omitempty" json:"planned_response_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
|
// FieldCodes declares the vocabulary of "field codes" used as
|
||||||
// components of tracking numbers and as constrained body fields
|
// components of tracking numbers and as constrained body fields
|
||||||
// on record YAMLs (mdl rows, rsk rows, ssr rows). The map key is
|
// on record YAMLs (mdl rows, rsk rows, ssr rows). The map key is
|
||||||
|
|
|
||||||
|
|
@ -226,45 +226,58 @@ func ChildrenDeclaredAt(fsRoot, dirPath string) []string {
|
||||||
|
|
||||||
// CanonicalFolderAt returns the canonical-folder name for THIS specific
|
// CanonicalFolderAt returns the canonical-folder name for THIS specific
|
||||||
// directory — one of "archive", "working", "staging", "reviewing",
|
// directory — one of "archive", "working", "staging", "reviewing",
|
||||||
// "incoming", "received", "issued", "mdl", "rsk" — or "" if the path
|
// "incoming", "received", "issued", "mdl" — or "" if the path is not
|
||||||
// is not at a canonical-folder slot.
|
// at a canonical-folder slot.
|
||||||
//
|
//
|
||||||
// Detection is structural against the canonical project layout declared
|
// Detection is structural against the canonical project layout declared
|
||||||
// in defaults.zddc.yaml:
|
// in defaults.zddc.yaml: top-level <project>/{archive,working,staging,
|
||||||
//
|
// reviewing} and the second-level archive/<party>/{mdl,incoming,
|
||||||
// - top-level <project>/archive is the only physical project-root
|
// received,issued}. Operators don't rename these slots (the cascade
|
||||||
// canonical slot (the working/staging/reviewing/ssr/mdl/rsk URLs
|
// keys them by literal name); a custom layout that does is on its own.
|
||||||
// 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,
|
// Used by the browse SPA to scope-gate context-menu actions (Accept,
|
||||||
// Stage/Unstage, Create Transmittal folder) without re-implementing the
|
// Stage/Unstage, Create Transmittal folder) without re-implementing the
|
||||||
// cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder header.
|
// cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder header.
|
||||||
func CanonicalFolderAt(fsRoot, dirPath string) string {
|
func CanonicalFolderAt(fsRoot, dirPath string) string {
|
||||||
segs := resolvePathSegments(fsRoot, dirPath)
|
segs := resolvePathSegments(fsRoot, dirPath)
|
||||||
// <project>/<folder> — only archive/ is physical at project root.
|
// <project>/<folder>
|
||||||
if len(segs) == 2 {
|
if len(segs) == 2 {
|
||||||
if segs[1] == "archive" {
|
switch segs[1] {
|
||||||
return "archive"
|
case "archive", "working", "staging", "reviewing":
|
||||||
|
return segs[1]
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// <project>/archive/<party>/<folder>
|
// <project>/archive/<party>/<folder>
|
||||||
if len(segs) == 4 && segs[1] == "archive" {
|
if len(segs) == 4 && segs[1] == "archive" {
|
||||||
switch segs[3] {
|
switch segs[3] {
|
||||||
case "incoming", "received", "issued", "mdl", "rsk",
|
case "incoming", "received", "issued", "mdl":
|
||||||
"working", "staging", "reviewing":
|
|
||||||
return segs[3]
|
return segs[3]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
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.
|
// leafLevel returns the deepest (most-specific) ZddcFile in chain.
|
||||||
// Caller's responsibility to check len(chain.Levels) > 0 — but
|
// Caller's responsibility to check len(chain.Levels) > 0 — but
|
||||||
// returns ZddcFile{} on empty for ergonomic chaining.
|
// returns ZddcFile{} on empty for ergonomic chaining.
|
||||||
|
|
@ -290,7 +303,7 @@ func isZeroZddcFile(zf ZddcFile) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" ||
|
if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" ||
|
||||||
zf.PlannedResponseDate != "" {
|
zf.PlannedResponseDate != "" || zf.OnPlanReview != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if len(zf.AvailableTools) > 0 {
|
if len(zf.AvailableTools) > 0 {
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,6 @@ import (
|
||||||
// TestDefaultToolAt_FromEmbeddedConvention — the canonical default-
|
// TestDefaultToolAt_FromEmbeddedConvention — the canonical default-
|
||||||
// tool rules in defaults.zddc.yaml should resolve correctly for the
|
// tool rules in defaults.zddc.yaml should resolve correctly for the
|
||||||
// well-known paths without any on-disk .zddc.
|
// 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) {
|
func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
resetCache()
|
resetCache()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
@ -23,20 +18,12 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
}{
|
}{
|
||||||
{filepath.Join(root, "Project-X", "archive"), "archive"},
|
{filepath.Join(root, "Project-X", "archive"), "archive"},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "tables"},
|
{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", "incoming"), "classifier"},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"},
|
{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", "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"), "browse"},
|
||||||
{filepath.Join(root, "Project-X", "staging"), "browse"},
|
{filepath.Join(root, "Project-X", "working", "alice@example.com"), "browse"},
|
||||||
|
{filepath.Join(root, "Project-X", "staging"), "transmittal"},
|
||||||
{filepath.Join(root, "Project-X", "reviewing"), "browse"},
|
{filepath.Join(root, "Project-X", "reviewing"), "browse"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|
@ -58,7 +45,7 @@ func TestDirToolAt(t *testing.T) {
|
||||||
// whose default_tool (no-slash form) is something else.
|
// whose default_tool (no-slash form) is something else.
|
||||||
for _, p := range []string{
|
for _, p := range []string{
|
||||||
filepath.Join(root, "Project-X"),
|
filepath.Join(root, "Project-X"),
|
||||||
filepath.Join(root, "Project-X", "archive", "Acme", "working"),
|
filepath.Join(root, "Project-X", "working"),
|
||||||
filepath.Join(root, "Project-X", "archive", "Acme", "mdl"),
|
filepath.Join(root, "Project-X", "archive", "Acme", "mdl"),
|
||||||
filepath.Join(root, "Project-X", "random", "deep", "folder"),
|
filepath.Join(root, "Project-X", "random", "deep", "folder"),
|
||||||
} {
|
} {
|
||||||
|
|
@ -86,14 +73,8 @@ func TestDirToolAt(t *testing.T) {
|
||||||
|
|
||||||
// TestCanonicalFolderAt — structural detection of the canonical
|
// TestCanonicalFolderAt — structural detection of the canonical
|
||||||
// project-layout slots that the browse SPA scope-gates context-menu
|
// project-layout slots that the browse SPA scope-gates context-menu
|
||||||
// actions against.
|
// actions against. Top-level <project>/<folder> and second-level
|
||||||
//
|
// <project>/archive/<party>/<folder>; everything else returns "".
|
||||||
// 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) {
|
func TestCanonicalFolderAt(t *testing.T) {
|
||||||
resetCache()
|
resetCache()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
@ -102,21 +83,16 @@ func TestCanonicalFolderAt(t *testing.T) {
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{filepath.Join(root, "Project-X", "archive"), "archive"},
|
{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", "incoming"), "incoming"},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "received"},
|
{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", "issued"), "issued"},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "mdl"},
|
{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, ""},
|
{root, ""},
|
||||||
{filepath.Join(root, "Project-X"), ""},
|
{filepath.Join(root, "Project-X"), ""},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), ""},
|
{filepath.Join(root, "Project-X", "working", "alice@example.com"), ""},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme"), ""},
|
{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", "archive", "Acme", "incoming", "2026-05-15_Acme-0042 (RFI) - Foundation"), ""},
|
||||||
{filepath.Join(root, "Project-X", "random", "dir"), ""},
|
{filepath.Join(root, "Project-X", "random", "dir"), ""},
|
||||||
|
|
@ -131,8 +107,7 @@ func TestCanonicalFolderAt(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for
|
// TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for
|
||||||
// the per-party lifecycle slots (working/staging/reviewing/incoming)
|
// working/incoming/staging (per the convention) and false elsewhere.
|
||||||
// and false for received/issued/mdl/rsk.
|
|
||||||
func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
resetCache()
|
resetCache()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
@ -140,15 +115,13 @@ func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
path string
|
path string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
|
{filepath.Join(root, "Project-X", "working"), true},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true},
|
{filepath.Join(root, "Project-X", "working", "alice@example.com"), true},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), true},
|
{filepath.Join(root, "Project-X", "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", "incoming"), true},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
{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", "issued"), false},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false},
|
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), false},
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
got := AutoOwnAt(root, tc.path)
|
got := AutoOwnAt(root, tc.path)
|
||||||
|
|
@ -159,9 +132,9 @@ func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVirtualAt_FromEmbeddedConvention — mdl/rsk under a party are
|
// TestVirtualAt_FromEmbeddedConvention — mdl/ is declared virtual;
|
||||||
// declared virtual, and the six project-level aggregators are virtual.
|
// everything else (including reviewing/, which is now Plan-Review-
|
||||||
// Other canonical slots materialise on disk.
|
// managed with physical workflow folders) materialises on disk.
|
||||||
func TestVirtualAt_FromEmbeddedConvention(t *testing.T) {
|
func TestVirtualAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
resetCache()
|
resetCache()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
@ -170,19 +143,11 @@ func TestVirtualAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true},
|
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), true},
|
{filepath.Join(root, "Project-X", "reviewing"), false},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), false},
|
{filepath.Join(root, "Project-X", "working"), false},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), false},
|
{filepath.Join(root, "Project-X", "staging"), 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", "incoming"), false},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), 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 {
|
for _, tc := range cases {
|
||||||
got := VirtualAt(root, tc.path)
|
got := VirtualAt(root, tc.path)
|
||||||
|
|
@ -205,11 +170,8 @@ func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) {
|
||||||
}{
|
}{
|
||||||
{filepath.Join(root, "Project-X", "archive"), true},
|
{filepath.Join(root, "Project-X", "archive"), true},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), 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", "working"), true},
|
||||||
{filepath.Join(root, "Project-X", "reviewing"), 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
|
{filepath.Join(root, "Project-X", "junk"), false}, // not in convention
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|
@ -221,17 +183,17 @@ func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project root
|
// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project
|
||||||
// the cascade declares archive/ plus the six top-level virtual
|
// root, the canonical children should be enumerated: the four
|
||||||
// aggregator slots (ssr, mdl, rsk, working, staging, reviewing).
|
// physical folders (archive, working, staging, reviewing) plus the
|
||||||
|
// three project-level virtual aggregator slots (ssr, mdl, rsk).
|
||||||
func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) {
|
func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
resetCache()
|
resetCache()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X"))
|
got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X"))
|
||||||
want := map[string]bool{
|
want := map[string]bool{
|
||||||
"archive": true,
|
"archive": true, "working": true, "staging": true, "reviewing": true,
|
||||||
"ssr": true, "mdl": true, "rsk": true,
|
"ssr": true, "mdl": true, "rsk": true,
|
||||||
"working": true, "staging": true, "reviewing": true,
|
|
||||||
}
|
}
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want)
|
t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want)
|
||||||
|
|
@ -249,19 +211,19 @@ func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
|
func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
|
||||||
resetCache()
|
resetCache()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Special", "archive", "Acme", "working"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, "Special", "working"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Operator declares that Special/archive/Acme/working uses
|
// Operator declares that Special/working uses classifier
|
||||||
// classifier instead of the embedded-default browse.
|
// instead of the embedded-default browse.
|
||||||
writeZddc(t, filepath.Join(root, "Special", "archive", "Acme", "working"),
|
writeZddc(t, filepath.Join(root, "Special", "working"),
|
||||||
"default_tool: classifier\n")
|
"default_tool: classifier\n")
|
||||||
|
|
||||||
if got := DefaultToolAt(root, filepath.Join(root, "Special", "archive", "Acme", "working")); got != "classifier" {
|
if got := DefaultToolAt(root, filepath.Join(root, "Special", "working")); got != "classifier" {
|
||||||
t.Errorf("operator override should set default_tool=classifier, got %q", got)
|
t.Errorf("operator override should set default_tool=classifier, got %q", got)
|
||||||
}
|
}
|
||||||
// Default still applies at other projects.
|
// Default still applies at other projects.
|
||||||
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "archive", "Acme", "working")); got != "browse" {
|
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "working")); got != "browse" {
|
||||||
t.Errorf("default convention should hold at unchanged paths, got %q", got)
|
t.Errorf("default convention should hold at unchanged paths, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -273,9 +235,8 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
|
||||||
func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) {
|
func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) {
|
||||||
resetCache()
|
resetCache()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
// Deep path under archive/<party>/working/ — not explicitly
|
// Deep path under working/ — not explicitly mentioned in paths:.
|
||||||
// mentioned in paths:.
|
deep := filepath.Join(root, "Project-X", "working", "alice@example.com", "notes", "sub", "deep")
|
||||||
deep := filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes", "sub", "deep")
|
|
||||||
if got := DefaultToolAt(root, deep); got != "browse" {
|
if got := DefaultToolAt(root, deep); got != "browse" {
|
||||||
t.Errorf("DefaultToolAt(%q) = %q, want browse (cascade propagation)",
|
t.Errorf("DefaultToolAt(%q) = %q, want browse (cascade propagation)",
|
||||||
deep[len(root):], got)
|
deep[len(root):], got)
|
||||||
|
|
@ -287,7 +248,7 @@ func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) {
|
||||||
func TestAutoOwnAt_DescendantCanDisable(t *testing.T) {
|
func TestAutoOwnAt_DescendantCanDisable(t *testing.T) {
|
||||||
resetCache()
|
resetCache()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
deepDir := filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com")
|
deepDir := filepath.Join(root, "Project-X", "working", "alice@example.com")
|
||||||
if err := os.MkdirAll(deepDir, 0o755); err != nil {
|
if err := os.MkdirAll(deepDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -296,7 +257,7 @@ func TestAutoOwnAt_DescendantCanDisable(t *testing.T) {
|
||||||
t.Errorf("AutoOwnAt(%q) = %v, want false (descendant override)", deepDir, got)
|
t.Errorf("AutoOwnAt(%q) = %v, want false (descendant override)", deepDir, got)
|
||||||
}
|
}
|
||||||
// Ancestor still has it true.
|
// Ancestor still has it true.
|
||||||
ancestor := filepath.Join(root, "Project-X", "archive", "Acme", "working")
|
ancestor := filepath.Join(root, "Project-X", "working")
|
||||||
if got := AutoOwnAt(root, ancestor); got != true {
|
if got := AutoOwnAt(root, ancestor); got != true {
|
||||||
t.Errorf("AutoOwnAt(%q) = %v, want true (ancestor untouched)", ancestor, got)
|
t.Errorf("AutoOwnAt(%q) = %v, want true (ancestor untouched)", ancestor, got)
|
||||||
}
|
}
|
||||||
|
|
@ -314,7 +275,7 @@ func TestInheritFalse_BlocksEmbeddedDefaults(t *testing.T) {
|
||||||
if IsDeclaredPath(root, filepath.Join(root, "Project-X", "archive")) {
|
if IsDeclaredPath(root, filepath.Join(root, "Project-X", "archive")) {
|
||||||
t.Errorf("with inherit:false at root, archive should not be a declared path")
|
t.Errorf("with inherit:false at root, archive should not be a declared path")
|
||||||
}
|
}
|
||||||
if DefaultToolAt(root, filepath.Join(root, "Project-X", "archive", "Acme", "working")) != "" {
|
if DefaultToolAt(root, filepath.Join(root, "Project-X", "working")) != "" {
|
||||||
t.Errorf("with inherit:false at root, default_tool should be empty for working")
|
t.Errorf("with inherit:false at root, default_tool should be empty for working")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,8 @@ import (
|
||||||
// - rw at the project level (read + overwrite-existing), but NOT c
|
// - rw at the project level (read + overwrite-existing), but NOT c
|
||||||
// (so it can't make arbitrary folders)
|
// (so it can't make arbitrary folders)
|
||||||
// - rwc at archive/ (can create party subfolders)
|
// - rwc at archive/ (can create party subfolders)
|
||||||
// - subtree-admin at archive/<party>/ (full create + manage; lifecycle
|
// - subtree-admin at working/ and staging/ (full create + manage)
|
||||||
// slots under the party inherit the admin grant)
|
|
||||||
// - inside received/issued (WORM): masked to r + worm-restored c
|
// - 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) {
|
func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
||||||
resetCache()
|
resetCache()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
@ -61,30 +56,21 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
||||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "rc")
|
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "rc")
|
||||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "rc")
|
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "rc")
|
||||||
|
|
||||||
// Subtree-admin at archive/<party>/ (the embedded cascade
|
// Subtree-admin at working/ and staging/ (via admins: [document_controller]
|
||||||
// declares admins: [document_controller] on the party "*" entry,
|
// in the embedded cascade — role-aware now).
|
||||||
// so working/staging/reviewing inside the party inherit it).
|
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "working"), Principal{Email: dc, Elevated: true}) {
|
||||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme"), Principal{Email: dc, Elevated: true}) {
|
t.Errorf("doc controller should be subtree-admin of working/")
|
||||||
t.Errorf("doc controller should be subtree-admin of archive/<party>/")
|
|
||||||
}
|
}
|
||||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working"), Principal{Email: dc, Elevated: true}) {
|
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "staging"), Principal{Email: dc, Elevated: true}) {
|
||||||
t.Errorf("doc controller should be subtree-admin of archive/<party>/working/")
|
t.Errorf("doc controller should be subtree-admin of staging/")
|
||||||
}
|
}
|
||||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "staging"), Principal{Email: dc, Elevated: true}) {
|
// NOT subtree-admin of archive/ (so WORM still binds them there).
|
||||||
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}) {
|
if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), Principal{Email: dc, Elevated: true}) {
|
||||||
t.Errorf("doc controller should NOT be subtree-admin of archive/ (only of each party folder)")
|
t.Errorf("doc controller should NOT be subtree-admin of archive/")
|
||||||
}
|
}
|
||||||
// Subtree-admin reaches inside a fenced per-user working home
|
// Subtree-admin reaches inside a fenced per-user working home.
|
||||||
// under the party's working slot.
|
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "working", "alice@example.com"), Principal{Email: dc, Elevated: true}) {
|
||||||
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 working/) should reach inside a fenced user home")
|
||||||
t.Errorf("doc controller (subtree-admin of party) should reach inside a fenced user home")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,9 +86,9 @@ func TestStandardRoles_ProjectTeamReadOnlyExceptOwned(t *testing.T) {
|
||||||
members: ["*@example.com"]
|
members: ["*@example.com"]
|
||||||
`)
|
`)
|
||||||
// Simulate the auto-own .zddc the file API would write at
|
// Simulate the auto-own .zddc the file API would write at
|
||||||
// archive/Acme/working/alice@example.com/ (fenced via
|
// working/alice@example.com/ (fenced via acl.inherit:false,
|
||||||
// acl.inherit:false, creator-owned).
|
// creator-owned).
|
||||||
homeDir := filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com")
|
homeDir := filepath.Join(root, "Proj", "working", "alice@example.com")
|
||||||
if err := os.MkdirAll(homeDir, 0o755); err != nil {
|
if err := os.MkdirAll(homeDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -140,69 +126,3 @@ created_by: alice@example.com
|
||||||
t.Errorf("alice in incoming/ = %q, want r (no create/write for project_team)", got.String())
|
t.Errorf("alice in incoming/ = %q, want r (no create/write for project_team)", got.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStandardRoles_ObserverReadOnlyEverywhere — observer is the
|
|
||||||
// project-wide read-only role for auditors / regulators / external
|
|
||||||
// viewers. Unlike project_team, an observer must not contribute
|
|
||||||
// content anywhere: no create at archive/, no create at working/,
|
|
||||||
// no worm-create at received/issued, and not subtree-admin of
|
|
||||||
// anything. Read passes through WORM zones (worm: lists strip w/d/a
|
|
||||||
// but never r).
|
|
||||||
func TestStandardRoles_ObserverReadOnlyEverywhere(t *testing.T) {
|
|
||||||
resetCache()
|
|
||||||
root := t.TempDir()
|
|
||||||
writeZddc(t, root, `roles:
|
|
||||||
observer:
|
|
||||||
members: ["auditor@example.com"]
|
|
||||||
`)
|
|
||||||
obs := "auditor@example.com"
|
|
||||||
|
|
||||||
mustVerbs := func(dir string, want string) {
|
|
||||||
t.Helper()
|
|
||||||
chain, err := EffectivePolicy(root, dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("EffectivePolicy(%q): %v", dir, err)
|
|
||||||
}
|
|
||||||
// Mirror InternalDecider.Allow's WORM-aware composition so the
|
|
||||||
// assertion covers received/issued correctly.
|
|
||||||
var got VerbSet
|
|
||||||
if g, inWorm := WormZoneGrant(chain, obs); inWorm {
|
|
||||||
got = (EffectiveVerbs(chain, obs) & VerbR) | (g & VerbsRC)
|
|
||||||
} else {
|
|
||||||
got = EffectiveVerbs(chain, obs)
|
|
||||||
}
|
|
||||||
if got.String() != want {
|
|
||||||
t.Errorf("observer verbs at %s = %q, want %q", dir[len(root):], got.String(), want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Project level: read-only.
|
|
||||||
mustVerbs(filepath.Join(root, "Proj"), "r")
|
|
||||||
// A random subfolder under the project still read-only.
|
|
||||||
mustVerbs(filepath.Join(root, "Proj", "random-folder"), "r")
|
|
||||||
// archive/ — read-only (no create at the party-folder level).
|
|
||||||
mustVerbs(filepath.Join(root, "Proj", "archive"), "r")
|
|
||||||
// incoming/ — read-only (no create even though incoming/ has
|
|
||||||
// drop_target and auto_own; the cascade ACL still gates create).
|
|
||||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "incoming"), "r")
|
|
||||||
// In-flight lifecycle slots — read-only.
|
|
||||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "working"), "r")
|
|
||||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "staging"), "r")
|
|
||||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "reviewing"), "r")
|
|
||||||
// WORM zones — read passes through; no worm-create (observer is
|
|
||||||
// not in the worm: list).
|
|
||||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "r")
|
|
||||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "r")
|
|
||||||
|
|
||||||
// Observer is not subtree-admin of anything in the project — even
|
|
||||||
// when notionally elevated, the role carries no admin grant.
|
|
||||||
if IsSubtreeAdmin(root, filepath.Join(root, "Proj"), Principal{Email: obs, Elevated: true}) {
|
|
||||||
t.Errorf("observer should NOT be subtree-admin of the project root")
|
|
||||||
}
|
|
||||||
if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), Principal{Email: obs, Elevated: true}) {
|
|
||||||
t.Errorf("observer should NOT be subtree-admin of archive/")
|
|
||||||
}
|
|
||||||
if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme"), Principal{Email: obs, Elevated: true}) {
|
|
||||||
t.Errorf("observer should NOT be subtree-admin of archive/<party>/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Virtual `received/` window — the doc-controller's Plan Review composite
|
// Virtual `received/` window — the doc-controller's Plan Review composite
|
||||||
// endpoint scaffolds physical folders under archive/<party>/reviewing/ and
|
// endpoint scaffolds physical folders under <reviewing_root> and
|
||||||
// archive/<party>/staging/, each carrying a .zddc whose `received_path:`
|
// <staging_root>, each carrying a .zddc whose `received_path:` points back
|
||||||
// points back at the canonical archive/<party>/received/<tracking>/. When
|
// at the canonical archive/<party>/received/<tracking>/. When a workflow
|
||||||
// a workflow folder is listed, the server injects a synthetic `received/`
|
// folder is listed, the server injects a synthetic `received/` child that
|
||||||
// child that shows the canonical submittal's contents in context.
|
// shows the canonical submittal's contents in context.
|
||||||
//
|
//
|
||||||
// Three behaviours rely on this:
|
// Three behaviours rely on this:
|
||||||
//
|
//
|
||||||
|
|
@ -51,7 +51,7 @@ func WorkflowReceivedPath(dirPath string) string {
|
||||||
type VirtualReceivedResolution struct {
|
type VirtualReceivedResolution struct {
|
||||||
Resolved bool
|
Resolved bool
|
||||||
WorkflowAbs string // absolute path of the workflow folder
|
WorkflowAbs string // absolute path of the workflow folder
|
||||||
WorkflowURL string // server-relative URL of the workflow folder, slash-terminated (e.g. "/Project/archive/Acme/reviewing/2026-05-30_X (TBD) - …/")
|
WorkflowURL string // server-relative URL of the workflow folder, slash-terminated (e.g. "/Project/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)
|
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
|
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")
|
SuffixURL string // URL suffix after the `/received/` segment, slash-prefixed when non-empty (e.g. "" or "Acme-0042_A (RFI) - Foundation.pdf")
|
||||||
|
|
|
||||||
|
|
@ -9,36 +9,15 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Virtual project-level views.
|
// Virtual project-level table views — SSR, MDL rollup, RSK rollup.
|
||||||
//
|
//
|
||||||
// Six aggregators live at <project>/, all sibling to the only real
|
// All three are declared `virtual: true` in defaults.zddc.yaml under
|
||||||
// top-level directory archive/. None of them materialise on disk; the
|
// `<project>/{ssr,mdl,rsk}`. The folder does not exist on disk: the
|
||||||
// server synthesises listings by walking archive/*/ at request time
|
// server synthesizes listings by walking archive/*/ at request time
|
||||||
// and (for the tables rollups) rewrites file reads/writes back to
|
// and rewrites file reads/writes back to canonical paths inside the
|
||||||
// canonical paths inside the per-party folders.
|
// per-party folders. ACL on each synthetic row is evaluated against
|
||||||
//
|
// the canonical `<project>/archive/<party>/` chain, so party owners
|
||||||
// Two aggregation shapes:
|
// can edit their own rows and non-owners see them read-only.
|
||||||
//
|
|
||||||
// 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
|
// URL conventions
|
||||||
//
|
//
|
||||||
|
|
@ -55,10 +34,6 @@ import (
|
||||||
//
|
//
|
||||||
// /<project>/rsk/ → analogous
|
// /<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
|
// Modeled on virtualreceived.go: one resolver produces canonical
|
||||||
// paths; every caller (listing builder, file API rewrite, form
|
// paths; every caller (listing builder, file API rewrite, form
|
||||||
// recognizer) reads its policy chain from the canonical path.
|
// recognizer) reads its policy chain from the canonical path.
|
||||||
|
|
@ -77,13 +52,6 @@ const (
|
||||||
VirtualViewRSKRoot
|
VirtualViewRSKRoot
|
||||||
VirtualViewRSKSpec
|
VirtualViewRSKSpec
|
||||||
VirtualViewRSKRow
|
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
|
// IsRowKind reports whether k targets a per-party row file (true for
|
||||||
|
|
@ -109,27 +77,15 @@ func (k VirtualViewKind) IsSpecKind() bool {
|
||||||
// virtual view.
|
// virtual view.
|
||||||
func (k VirtualViewKind) IsRootKind() bool {
|
func (k VirtualViewKind) IsRootKind() bool {
|
||||||
switch k {
|
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 true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// VirtualViewResolution captures the result of mapping a URL onto
|
// VirtualViewResolution captures the result of mapping a URL onto
|
||||||
// one of the project-level virtual views. All fields are populated
|
// one of the project-level virtual table views. All fields are
|
||||||
// only when Resolved is true.
|
// populated only when Resolved is true.
|
||||||
type VirtualViewResolution struct {
|
type VirtualViewResolution struct {
|
||||||
Resolved bool
|
Resolved bool
|
||||||
Kind VirtualViewKind
|
Kind VirtualViewKind
|
||||||
|
|
@ -138,7 +94,7 @@ type VirtualViewResolution struct {
|
||||||
ProjectURL string // "/<project>/"
|
ProjectURL string // "/<project>/"
|
||||||
ProjectAbs string // <fsRoot>/<project>
|
ProjectAbs string // <fsRoot>/<project>
|
||||||
|
|
||||||
Slot string // "ssr", "mdl", "rsk", "working", "staging", "reviewing"
|
Slot string // "ssr", "mdl", or "rsk"
|
||||||
SlotURL string // "/<project>/<slot>/"
|
SlotURL string // "/<project>/<slot>/"
|
||||||
|
|
||||||
// Populated for VirtualView*Spec kinds: "table.yaml" or "form.yaml".
|
// Populated for VirtualView*Spec kinds: "table.yaml" or "form.yaml".
|
||||||
|
|
@ -151,18 +107,12 @@ type VirtualViewResolution struct {
|
||||||
CanonicalURL string // /<project>/archive/<party>/...
|
CanonicalURL string // /<project>/archive/<party>/...
|
||||||
SchemaAbs string // SSR only — <party>/ssr.form.yaml (may not exist; falls back to embedded)
|
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"
|
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
|
// virtualViewRE matches /<project>/<slot>[/<rest>] where slot is one
|
||||||
// of the canonical virtual view names. Capture 1 = project, capture
|
// of the canonical virtual view names. Capture 1 = project, capture
|
||||||
// 2 = slot, capture 3 = rest (may be empty).
|
// 2 = slot, capture 3 = rest (may be empty).
|
||||||
var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(ssr|mdl|rsk|working|staging|reviewing)(?:/(.*))?$`)
|
var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(ssr|mdl|rsk)(?:/(.*))?$`)
|
||||||
|
|
||||||
// partyNameRE matches the SSR schema's `name` pattern. Same regex
|
// partyNameRE matches the SSR schema's `name` pattern. Same regex
|
||||||
// used at row-resolution time so URLs with invalid party tokens fail
|
// used at row-resolution time so URLs with invalid party tokens fail
|
||||||
|
|
@ -176,47 +126,9 @@ func ValidPartyName(s string) bool {
|
||||||
return partyNameRE.MatchString(s)
|
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
|
// ResolveVirtualView inspects urlPath and returns a populated
|
||||||
// resolution iff the URL targets one of the project-level virtual
|
// resolution iff the URL targets one of the project-level virtual
|
||||||
// views (ssr/, mdl/, rsk/, working/, staging/, reviewing/).
|
// views (ssr/, mdl/, rsk/). On a non-match, Resolved=false.
|
||||||
// Resolved=false on non-match.
|
|
||||||
//
|
//
|
||||||
// The resolver does NOT check that the project / party / row file
|
// The resolver does NOT check that the project / party / row file
|
||||||
// actually exist on disk — that's the caller's job (handlers use
|
// actually exist on disk — that's the caller's job (handlers use
|
||||||
|
|
@ -253,48 +165,18 @@ func ResolveVirtualView(fsRoot, urlPath string) VirtualViewResolution {
|
||||||
out.SlotURL = "/" + project + "/" + slot + "/"
|
out.SlotURL = "/" + project + "/" + slot + "/"
|
||||||
|
|
||||||
if rest == "" {
|
if rest == "" {
|
||||||
if IsFolderNavSlot(slot) {
|
switch slot {
|
||||||
out.Kind = VirtualViewFolderNavRoot
|
case "ssr":
|
||||||
} else {
|
out.Kind = VirtualViewSSRRoot
|
||||||
switch slot {
|
case "mdl":
|
||||||
case "ssr":
|
out.Kind = VirtualViewMDLRoot
|
||||||
out.Kind = VirtualViewSSRRoot
|
case "rsk":
|
||||||
case "mdl":
|
out.Kind = VirtualViewRSKRoot
|
||||||
out.Kind = VirtualViewMDLRoot
|
|
||||||
case "rsk":
|
|
||||||
out.Kind = VirtualViewRSKRoot
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
out.Resolved = true
|
out.Resolved = true
|
||||||
return out
|
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" {
|
if rest == "table.yaml" || rest == "form.yaml" {
|
||||||
switch slot {
|
switch slot {
|
||||||
case "ssr":
|
case "ssr":
|
||||||
|
|
@ -501,52 +383,3 @@ func ListRollupRows(fsRoot, projectAbs, slot string) ([]VirtualRollupRow, error)
|
||||||
})
|
})
|
||||||
return out, nil
|
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,12 +121,13 @@ func TestResolveVirtualView_NonMatches(t *testing.T) {
|
||||||
"/",
|
"/",
|
||||||
"/Project",
|
"/Project",
|
||||||
"/Project/",
|
"/Project/",
|
||||||
|
"/Project/working",
|
||||||
"/Project/archive/Acme/mdl",
|
"/Project/archive/Acme/mdl",
|
||||||
"/Project/ssr/invalid__name__double.yaml", // double-double underscore is rejected
|
"/Project/ssr/invalid__name__double.yaml", // double-double underscore is rejected
|
||||||
"/Project/mdl/__leading.yaml", // empty party
|
"/Project/mdl/__leading.yaml", // empty party
|
||||||
"/Project/mdl/party__.yaml", // empty rowBase
|
"/Project/mdl/party__.yaml", // empty rowBase
|
||||||
"/Project/ssr/.hidden.yaml", // dotfile party name
|
"/Project/ssr/.hidden.yaml", // dotfile party name
|
||||||
"/Project/ssr/0330C1.yaml/sub", // sub-path under row file
|
"/Project/ssr/0330C1.yaml/sub", // sub-path under row file
|
||||||
"/Project/notaslot/table.yaml",
|
"/Project/notaslot/table.yaml",
|
||||||
}
|
}
|
||||||
for _, url := range cases {
|
for _, url := range cases {
|
||||||
|
|
@ -137,158 +138,6 @@ 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) {
|
func TestIsSSRCreateURL(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
url string
|
url string
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
||||||
if top.PlannedResponseDate != "" {
|
if top.PlannedResponseDate != "" {
|
||||||
out.PlannedResponseDate = top.PlannedResponseDate
|
out.PlannedResponseDate = top.PlannedResponseDate
|
||||||
}
|
}
|
||||||
|
if top.OnPlanReview != nil {
|
||||||
|
out.OnPlanReview = top.OnPlanReview
|
||||||
|
}
|
||||||
out.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools)
|
out.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools)
|
||||||
|
|
||||||
out.Admins = mergeStringSlice(out.Admins, top.Admins)
|
out.Admins = mergeStringSlice(out.Admins, top.Admins)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue