diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 77041f3..4248003 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -504,8 +504,8 @@ none of them is load-bearing alone. |---|---|---| | Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer ` validated against `/.zddc.d/tokens/` (CLI / scripted callers); or `X-Auth-Request-Email` injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required | | Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` | -| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, walked deepest-first first-match-wins under `--cascade-mode=delegated` or with absolute ancestor denies under `--cascade-mode=strict` (`zddc/internal/zddc/acl.go`, `cascade.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data | -| Special folders | Codify the bilateral exchange-record archetype | `Incoming`/`Working`/`Staging` get auto-ownership on mkdir (creator gets `rwcda` via an auto-written `.zddc`); `Issued`/`Received` enforce a server-side WORM split (ancestor grants masked to `r`; only an explicit `.zddc` at-or-below the WORM folder can grant `c` for a write-once drop-box). Admins exempt. `zddc/internal/zddc/special.go` | +| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in `defaults.zddc.yaml` bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins under `--cascade-mode=delegated`, or with absolute ancestor denies under `--cascade-mode=strict` (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data | +| Canonical-folder behaviour | Codify the bilateral exchange-record archetype | All driven by `.zddc` keys (baked into `defaults.zddc.yaml`): `auto_own:` / `auto_own_fenced:` — mkdir here writes a creator-owned `.zddc` (`: rwcda`; fenced adds `acl.inherit:false`); `worm: [principal…]` — write-once-read-many (`w`/`d`/`a` stripped for everyone non-admin, `c` survives only for the listed principals; admins exempt); `virtual:` — never materialise on disk; `drop_target:` — browse shows a drag-drop upload overlay. The defaults put `auto_own` on `working`/`staging`/`archive-party`/`incoming` and `worm: [document_controller]` on `archive//{received,issued}`, so the canonical convention is unchanged — but an operator can reshape it (rename `received`/`issued`, mark any path WORM, …) without a code change. `zddc/internal/zddc/lookups.go`, `worm.go`, `roles.go`; `defaults.zddc.yaml` | | Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above | | URL canonicalization | Resolve URL paths to on-disk casing before any layer below sees them | `zddc/internal/fs/resolve.go ResolveCanonical` — case-insensitive lookup with lowercase-wins tiebreak when sibling case variants exist on disk. File and folder names preserve case on disk; the canonicalization is purely URL→FS-name mapping. Virtual prefixes (`.archive`, `.profile`, `.tokens`) flow through verbatim | | Reserved hidden prefixes | Hide operator side-state (caches, dev-shell home dirs) from listings and direct fetch | `.`-prefixed → 404 + listing-filtered; `_`-prefixed → listing-filtered only | diff --git a/CLAUDE.md b/CLAUDE.md index aa3f01a..a7edce1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,8 +23,8 @@ This is a **monorepo of independent tools**, not one application: - `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/`, `form/`, `tables/`, `browse/` — eight 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 `.form.yaml` file in the tree becomes an editable form at `/.form.html`); `tables/` is its read/aggregate counterpart, rendering a directory of YAML rows as a sortable table; `browse/` is the file-tree navigator. 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 ` validated against self-issued tokens at `/.zddc.d/tokens/` 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 ` 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/` — `base.css` plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `theme.js`, `help.js`) included by every tool's build, and `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers). -- **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 `-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`_v.html`) are immutable; partial-version pins (`_v.html`, `_v.html`) and channel mirrors (`_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v_` per-version binaries plus channel/partial-version symlinks plus `zddc-server_.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 eight HTML tools baked in via `//go:embed` (compile-time default). Tools auto-served at folder-name-driven paths: `archive` everywhere, `classifier` in `Incoming`/`Working`/`Staging` subtrees, `mdedit` in `Working` subtrees, `transmittal` in `Staging` subtrees, `landing` only at root. Override via `.zddc apps:` cascade entry (channel/version/URL/path) — fetched once, cached at `/_app/`. Drop a real `.html` file at any path to override. +- `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 `-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`_v.html`) are immutable; partial-version pins (`_v.html`, `_v.html`) and channel mirrors (`_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v_` per-version binaries plus channel/partial-version symlinks plus `zddc-server_.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 eight 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 `` — `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `classifier` under `incoming/`, `tables` at `archive//mdl`, `landing` at root), `dir_tool` (served at `/`; 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 `/_app/`; or drop a real `.html` at any path. See AGENTS.md "URL handling"/"Install model" and ARCHITECTURE.md "Canonical folders, URL routing & the `.zddc` cascade". - `helm/` — example Helm charts for zddc-server. Three flavors: `zddc-server-prod/` (production master), `zddc-server-dev/` (development master with OverlayFS isolation), `zddc-server-cache/` (downstream client running in proxy/cache/mirror mode against an upstream master, with bearer token from a Kubernetes Secret). All compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo — the cache chart references a separately-created Secret for the bearer token. - `tests/` — Playwright specs (Chromium only, requires File System Access API). `tests/schema.spec.js` validates `transmittal.schema.json` against canonical fixtures via `ajv` (only dev dep besides Playwright) diff --git a/README.md b/README.md index caf70f1..7920ab1 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The name "Zero Day Document Control" comes from the convention itself — adopt | **Browse** | File-tree navigator with previews; the everywhere-available companion to the Archive Browser when you want plain folder navigation rather than tracking-number aggregation. | | **Landing** | The project picker served at the deployment root of a `zddc-server`. | -Each tool is published in three channels (stable, beta, alpha) as static files served from . **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Tools auto-appear at folder-name-driven paths (archive everywhere; classifier in `Incoming`/`Working`/`Staging`; mdedit in `Working`; transmittal in `Staging`). Override per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path). URL overrides are fetched once and cached in `/_app/`; drop a real `.html` file at any path to override entirely. +Each tool is published in three channels (stable, beta, alpha) as static files served from . **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Which tool a directory URL serves is driven by the `.zddc` cascade: a baked-in `defaults.zddc.yaml` (dump it with `zddc-server show-defaults`) declares, per folder, `default_tool` (the no-slash form — archive under `archive/`, transmittal under `staging/`, mdedit under `working/`, classifier under `incoming/`, tables at `archive//mdl`, landing at root) and `dir_tool` (the trailing-slash form; defaults to `browse`); operators override at any level. A `.zip` file is also a navigable directory (`GET …/Foo.zip/`), and `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path) — fetched once and cached in `/_app/` — or drop a real `.html` file at any path. ## File-naming convention diff --git a/zddc/README.md b/zddc/README.md index 4de72fd..5e749a3 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -381,7 +381,7 @@ roles: members: [dc@mycompany.com, alice@mycompany.com] ``` -Members are email patterns using the same glob syntax as legacy `acl.allow`. Underscore-prefixed names are conventional (`_doc_controller`, `_company`) but not magic. A role redefined closer to the leaf shadows the ancestor's definition. Permission-map keys without `@` are treated as role references first; if no role of that name exists in the visible cascade, they fall back to legacy email-pattern matching (so `*@example.com` and bare `*` continue to work). +Members are email patterns using the same glob syntax as legacy `acl.allow`. Underscore-prefixed names are conventional (`_doc_controller`, `_company`) but not magic. **Role membership UNIONS across the cascade** — a `.zddc` that defines `vendor_acme` again with one extra member *adds* that member to the inherited role; use `reset: true` on the role at a level to break the union (ancestor definitions above the reset are then excluded). Permission-map keys without `@` are treated as role references first; if no role of that name exists in the visible cascade, they fall back to legacy email-pattern matching (so `*@example.com` and bare `*` continue to work). The baked-in `defaults.zddc.yaml` ships two empty standard roles — `document_controller` and `project_team` — referenced by the default ACLs; a deployment populates their members. ### Step 1: starter `.zddc` @@ -478,8 +478,11 @@ Behaviour: - **Admins:** the root `admins:` list is unaffected. Root admins still bypass all ACL evaluation, fence or no fence — that's the deliberate escape hatch for misfiled documents. -- **WORM:** the `archive//issued|received/` mask is path-based, - not cascade-based. `inherit:` does not change WORM behaviour. +- **WORM:** a `worm:` zone (declared by a `worm: [principal…]` key on a + `.zddc` — the baked-in `defaults.zddc.yaml` puts it on + `archive//{received,issued}`) is independent of the `inherit:` + fence; `inherit: false` does not change WORM behaviour. See + "Canonical-folder behaviour via `.zddc` keys" below. **Strict cascade mode IGNORES `inherit: false`.** NIST AC-6 requires ancestor explicit-denies to be absolute, and the inherit directive @@ -499,21 +502,49 @@ Implementation: parser (`zddc/internal/zddc/file.go`), `PolicyChain.VisibleStart` (`zddc/internal/zddc/cascade.go`), and the fence-aware role walk (`zddc/internal/zddc/roles.go`). -#### Special folders +#### Canonical-folder behaviour via `.zddc` keys -Five folder names trigger built-in behaviors regardless of cascade mode (canonical list in `zddc/internal/zddc/special.go`): +**There are no hardcoded folder names.** The canonical project structure +(`archive/`, `working/`, `staging/`, `reviewing/`; `archive//{mdl, +incoming,received,issued}/`) and its built-in behaviours are described by a +baked-in baseline `.zddc` — `zddc/internal/zddc/defaults.zddc.yaml`, the +bottom layer of every cascade, dumpable with `zddc-server show-defaults` — that +uses a recursive `paths:` tree to declare subfolder rules even before those +folders exist on disk. Operators override at the on-disk root (or any deeper +level) by mirroring the structure and changing what they need; setting +file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer +entirely (the structural convention included, not just the default ACLs). -- **`Incoming`, `Working`, `Staging`** — *auto-ownership*. When the file API processes `POST /// X-ZDDC-Op: mkdir` and the parent is one of these three, the server writes a `.zddc` into the new folder containing `created_by: ` and `permissions: { : rwcda }`. The grant uses the same direct email-pattern form an operator would write by hand; the creator can edit the `.zddc` later to add collaborators. `created_by` is an audit field — the cascade evaluator does not consult it. -- **`Issued`, `Received`** — *write-once / immutable archive*. When a request path crosses an `Issued` or `Received` segment, the server applies a **WORM split**: cascade grants inherited from ancestors above the WORM folder are masked to `r` only; grants at-or-below the WORM folder retain `r,c`. Anyone with `w`/`d`/`a` from inheritance loses those verbs once they enter the archive. To grant write-once (`cr`) to the doc controller, the operator places an explicit `.zddc` at the `Issued` or `Received` folder: +The keys that drive built-in behaviour: - ```yaml - # //Issued/.zddc - acl: - permissions: - _doc_controller: cr - ``` +| Key | Effect | +|---|---| +| `default_tool` | tool served at `` (no trailing slash) — the "specialized app": `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `classifier` under `incoming/`, `tables` at `archive//mdl`, `landing` at root. Cascades leaf→root. | +| `dir_tool` | tool served at `/` (trailing slash) — the directory view; floors at `browse`. Cascades leaf→root. (JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless.) | +| `auto_own` / `auto_own_fenced` | mkdir here writes a creator-owned `.zddc` (`created_by: ` + `permissions: { : rwcda }` — the same direct form an operator would write; the creator can edit it later to add collaborators; `created_by` is an audit field, not consulted by the evaluator). `auto_own_fenced` additionally sets `acl.inherit: false` (private to creator). Defaults: `auto_own` on `working`/`staging`/`archive/`/`incoming`; fenced on the per-user `working//` homes. | +| `worm` | `worm: [principal…]` marks a **write-once-read-many** zone: `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 cascade ACL granted; admins (root / subtree) bypass entirely — the escape hatch for misfiled documents. Defaults: `worm: [document_controller]` on `archive//{received,issued}` — so filing into the archive is write-once for the doc controller and immutable for everyone else (same effect as the old hardcoded "WORM split", but the operator can rename `received`/`issued`, mark any path WORM, or add more controllers, without a code change). | +| `available_tools` | tools the server may auto-serve at this path (cascade-unioned leaf→root). | +| `virtual` | the directory is never materialised on disk (e.g. `reviewing/`, `archive//mdl`). | +| `drop_target` | the browse tool shows a drag-drop upload overlay here (surfaced via the `X-ZDDC-Drop-Target` response header). | +| `roles` | `{ name → { members: [...], reset: bool } }` — see "Roles" above (union across the cascade; `reset: true` breaks it). | +| `admins` | subtree-admin principals (email globs or role names) — get unconditional `rwcda` over the subtree and bypass the cascade + WORM. | +| `paths` | recursive map ` → <.zddc overlay>` — the engine of the whole convention; the walker threads each ancestor's `paths:` contributions down to the right level. | - The mask preserves the `c` from this same-level grant, so the doc controller can file new documents — but they still cannot overwrite, delete, or change the ACL. **Only admins (root or subtree) can mutate filed documents.** The mask is server-enforced and not configurable in v1; operators who want a non-WORM directory must avoid the names `Issued` and `Received`. +A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON +listing of its members (or the browse SPA for an HTML request), and +`GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member (Range / ETag +supported); `GET …/Foo.zip` (no trailing slash) is unchanged — the raw `.zip` +download; write methods to a path inside a `.zip` are rejected (405). And +`GET /dir/?zip=1` streams an `application/zip` of every readable file under +`/dir/`, recursively, ACL-filtered (`Content-Disposition: attachment; +filename=".zip"`). + +The baked-in `defaults.zddc.yaml` is the authoritative, heavily-commented +reference for all of the above — `zddc-server show-defaults` prints it. +Implementation: `zddc/internal/zddc/walker.go` (`mergeOverlay`, the `paths:` +walk), `lookups.go` (`DefaultToolAt`/`DirToolAt`/`AutoOwnAt`/…), `worm.go`, +`roles.go`; the file API's mkdir hook (`zddc/internal/handler/fileapi.go`) and +`zddc/internal/zddc/ensure.go` seed auto-own `.zddc`s via `AutoOwnAt`. ### Glob patterns