May 2026 reshape. archive/ is now the only physical project-root
directory; working/, staging/, reviewing/ move from the project root
into each archive/<party>/ folder. Six top-level URLs become virtual
aggregators served via the cascade rather than disk:
ssr/mdl/rsk tables rollups across parties with a
synthesised $party source-party column
working/staging/ browse folder-nav listings of parties with
reviewing non-empty content in the slot; per-party
URLs 302-redirect to archive/<party>/<slot>/
Mkdir at the project root is restricted to `archive` and `_`/`.`-
prefixed system names — virtual aggregator names and ad-hoc folders
return 409.
Plan Review hardcodes the scaffold convention (archive/<party>/
{reviewing,staging}/<tracking>/); the pre-reshape
on_plan_review.{reviewing_root,staging_root} cascade keys are dropped.
document_controller is now subtree-admin of every archive/<party>/
(not of project-root working/staging/ as before), so per-party
lifecycle slots inherit admin authority through the cascade.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
719 lines
70 KiB
Markdown
719 lines
70 KiB
Markdown
# AGENTS.md — ZDDC
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
# ── ./build subcommands ────────────────────────────────────────────────────
|
|
# `./build` (no arg) is a source-side dev build only — assembles tool/dist/
|
|
# + cross-compiles zddc-server. dist/release-output/ and the live site are
|
|
# left alone. `./build beta` is an internal SHA snapshot for the BMC dev
|
|
# chart (no public artifacts). `./build release` is the canonical stable
|
|
# cut. Run `./deploy` to publish a stable cut.
|
|
|
|
./build # dev build (no release bundle)
|
|
./build beta # internal SHA snapshot for BMC dev chart
|
|
# (regenerates embedded/* + chore commit;
|
|
# no public artifacts in dist/release-output/)
|
|
./build release # coordinated stable cut, next version
|
|
# (tags all 8 artifacts at release commit)
|
|
./build release 1.2.0 # coordinated stable cut, explicit version
|
|
./build help
|
|
|
|
# ── ./deploy subcommands ────────────────────────────────────────────────────
|
|
# rsync the build output and content repo to /srv/zddc/ (Caddy's bind-mount).
|
|
# --delete-after — the live tree exactly mirrors source.
|
|
|
|
./deploy # full sync (content + releases)
|
|
./deploy --content # only ~/src/zddc-website/ → /srv/zddc/
|
|
./deploy --releases # only dist/release-output/ → /srv/zddc/releases/
|
|
|
|
# Single-tool dev build for testing (does NOT touch dist/release-output/):
|
|
sh tool/build.sh # archive|transmittal|classifier|landing|form|tables|browse
|
|
|
|
# Single-tool stable cut (rare; prefer ./build release so versions don't
|
|
# drift between tools).
|
|
sh tool/build.sh --release [<version>]
|
|
|
|
# Test all tools
|
|
npm test
|
|
|
|
# Test single tool
|
|
npx playwright test tool # archive | transmittal | classifier | browse | form-safety | tables
|
|
|
|
# Dev server (cache-busting HTTP, on port 8000)
|
|
./dev-server start
|
|
./dev-server stop
|
|
```
|
|
|
|
No lint, typecheck, or format commands exist — the project is plain sh + vanilla JS.
|
|
|
|
Stable cuts seed `dist/release-output/` from the current
|
|
`/srv/zddc/releases/` — copying only immutable per-version files
|
|
(`<tool>_v<X.Y.Z>.html`, `zddc-server_v<X.Y.Z>_<plat>`) + their `.sig`
|
|
sidecars + `pubkey.pem`. The cut writes this version's per-version
|
|
file + canonical `<tool>.html` / `zddc-server_<plat>` symlinks on top.
|
|
`./deploy --releases` (rsync `--delete-after`) cleanses any stale
|
|
files in the live tree that this cut didn't include.
|
|
|
|
**Nothing is pushed automatically.** Run `./deploy` to publish; commit
|
|
+ push source changes to `main` separately.
|
|
|
|
## Architecture
|
|
|
|
Seven independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `landing`, `form`, `tables`, `browse`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. `form` is the schema-driven renderer used by zddc-server's form-data system; `tables` is its read/aggregate counterpart, rendering a directory of YAML files as a sortable table whose rows click through to the form editor — discovered presence-based via `<name>.table.yaml` next to a sibling `<name>/` rows-dir (see "Form-data system" and "Tables system" below). `browse` is the file-tree navigator and also hosts the in-place markdown editor (`browse/js/preview-markdown.js`); the dedicated `mdedit/` tool has been retired.
|
|
|
|
```
|
|
tool/
|
|
css/ source stylesheets (concatenated in order)
|
|
js/ vanilla JS IIFEs (concatenated in order)
|
|
template.html placeholder markers: {{CSS_PLACEHOLDER}}, {{JS_PLACEHOLDER}}, {{BUILD_LABEL}}
|
|
build.sh assembles dist/tool.html
|
|
dist/tool.html generated output — committed with `git add -f`
|
|
|
|
shared/
|
|
base.css CSS tokens and primitives included first by every tool's CSS build
|
|
zddc.js canonical filename/folder/revision parsers, formatters, status validation
|
|
zddc-filter.js shared ZDDC project/status filter UI module
|
|
zddc-source.js HTTP source abstraction — FS Access API polyfill (HttpDirectoryHandle,
|
|
HttpFileHandle) backed by zddc-server's listing JSON + file API
|
|
(PUT/DELETE/POST). Tools that auto-load the current dir in HTTP mode
|
|
call window.zddc.source.detectServerRoot() at init. The probe
|
|
returns { handle, status }: status 200 → use handle; 403 → user
|
|
lacks `r` on this directory (show "no permission to list"
|
|
message); 0 → not http(s) or non-zddc-server. Tools must
|
|
handle the 403 case so a permission-locked path doesn't
|
|
silently render as an empty welcome screen.
|
|
hash.js SHA-256 helpers used by the file API + classifier hashes
|
|
theme.js light/dark theme switcher
|
|
help.js shared help dialog module
|
|
build-lib.sh POSIX sh helpers (ensure_exists, concat_files, build_timestamp)
|
|
sourced by every tool's build.sh via: . "$root_dir/../shared/build-lib.sh"
|
|
|
|
# Hand-edited website content lives in a SEPARATE Codeberg repo
|
|
# (codeberg.org/VARASYS/ZDDC-website), typically cloned at
|
|
# ~/src/zddc-website/. Just content — no releases, no LFS:
|
|
# index.html, reference.html, css/, js/, img/ hand-edited content
|
|
# README.md, LICENSE repo housekeeping
|
|
#
|
|
# This repo's ./build produces a release bundle in dist/release-output/
|
|
# (gitignored, local-only). ./deploy rsyncs both into /srv/zddc/ on
|
|
# the deploy host (Caddy's bind-mount):
|
|
# /srv/zddc/
|
|
# index.html, reference.html, css/, js/, img/ ← from ~/src/zddc-website
|
|
# releases/
|
|
# index.html regenerated by `./build`
|
|
# <tool>_v<X.Y.Z>.html per-version (immutable)
|
|
# <tool>_v<X.Y>.html -> ... symlink chain
|
|
# <tool>.html -> ... canonical symlink → current stable
|
|
# zddc-server_v<X.Y.Z>_<platform> per-platform binary (raw bytes, no LFS)
|
|
# zddc-server_<platform> canonical per-platform symlink → current stable
|
|
# zddc-server_<X>.html stub page surfacing 4 platform DLs
|
|
|
|
helm/
|
|
zddc-server-prod/ production-shaped Helm chart (compiles from source via init container)
|
|
zddc-server-dev/ dev-shaped variant (tracks main HEAD; debug-level logging; faster probes)
|
|
README.md chart design rationale + quick-start
|
|
```
|
|
|
|
**Critical:** `dist/` files are gitignored. `tool/dist/<tool>.html` is the canonical built artifact for testing and the source for `--release` writes into `dist/release-output/`. `dist/release-output/` is the local-only release bundle. Neither is in git. Never edit them directly.
|
|
|
|
**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/`). The live site at `zddc.varasys.io` is `/srv/zddc/` on the deploy host (Caddy bind-mount), populated by `./deploy`. Release artifacts are NOT in git — they're produced by `./build release` into `dist/release-output/` and rsync'd to `/srv/zddc/releases/` by `./deploy --releases`. Each tool has exactly one canonical URL (`<tool>.html`, symlink → current stable) and a set of per-version immutable files (`<tool>_v<X.Y.Z>.html`). Same shape for zddc-server per platform. `shared/build-lib.sh` provides `promote_release` (HTML tools) and `promote_zddc_server` (binaries + matching stub pages); the top-level `./build` seeds per-version immutables from live state, then calls them in lockstep. Older releases are reproducible from any `<tool>-vX.Y.Z` tag in this repo (`git checkout zddc-server-v0.0.8 && ./build release 0.0.8`). No Codeberg release assets, no LFS.
|
|
|
|
## Shared CSS (`shared/base.css`)
|
|
|
|
Included as the **first** positional arg to every tool's `concat_files` CSS call. Provides:
|
|
- `:root` CSS custom properties — `--primary`, `--bg`, `--text`, `--border`, `--font`, etc.
|
|
- Brand color: `--primary: #2a5a8a` (matches zddc.varasys.io)
|
|
- Button primitive: `.btn`, `.btn-primary`, `.btn-secondary`, `.btn-sm`, `.btn-lg`, `.btn-link`
|
|
- `.app-header` + `.app-header__title` chrome rules
|
|
- `.build-timestamp`, `.hidden`, `.truncate`, webkit scrollbars
|
|
|
|
**Do not** define these in any tool's own CSS — they come from shared.
|
|
|
|
**Toast CSS** lives in `classifier/css/base.css` only (classifier is the only tool that uses toasts).
|
|
|
|
## Transmittal CSS quirks
|
|
|
|
- `transmittal/css/base.css` overrides `html { font-size: 16px }` inside `@media screen` — this must stay. `shared/base.css` sets `14px`; transmittal's floating labels are rem-based and were designed for 16px.
|
|
- The floating label position is defined in `transmittal/css/forms.css`, not Tailwind classes. If adding new Tailwind classes to `template.html`, add them to `transmittal/css/utilities.css` too — there is no Tailwind build step.
|
|
|
|
## Build system rules
|
|
|
|
- Every `build.sh` sources `shared/build-lib.sh` first (provides `ensure_exists`, `concat_files`, `build_timestamp`). Set `root_dir` before sourcing.
|
|
- Build scripts use **POSIX sh** (`#!/bin/sh` with `set -eu`), not bash.
|
|
- `concat_files` accepts **positional args only** (not array names).
|
|
- `awk` processes `template.html`, replacing `{{PLACEHOLDER}}` markers and stripping CDN `<script>`/`<link>` tags (pattern: `https?://`)
|
|
- `{{BUILD_LABEL}}` is substituted in all seven HTML tools via `gsub` in awk (use `gsub`, not `print` — the placeholder is inline in an HTML line). Value is `v<next>-dev · <ts> · <sha>[-dirty]` for plain dev builds, `v<next>-beta · <ts> · <sha>` for `./build beta` snapshot cuts, and `v<X.Y.Z>` for stable releases; computed before the awk step. The shared `is_red` flag controls whether the label is wrapped in a red+bold `<span>` (true for dev/beta, false for stable).
|
|
- Cleans up temp files via `trap cleanup EXIT`
|
|
|
|
**`</` escaping is mandatory.** Any JS containing `</tag>` inside string or template literals will break inline `<script>` embedding. Run:
|
|
```bash
|
|
sed 's#</#<\\/#g' "$input_js" > "$safe_js"
|
|
```
|
|
Required for any new tool with vendor JS or JS containing HTML template literals.
|
|
|
|
## JS module pattern
|
|
|
|
All JS is vanilla, no bundlers. Files are IIFEs, registered on `window.app.modules`. Load order = declaration order in `build.sh`. `window.app` is the only global.
|
|
|
|
```javascript
|
|
(function() {
|
|
window.app.modules.mymodule = { ... };
|
|
})();
|
|
```
|
|
|
|
**Exception:** archive uses plain globals (`APP_STATE`, top-level functions) — not the IIFE/modules pattern.
|
|
|
|
**State values used inside event handlers must be read fresh from the source of truth, never captured at mount.** No bundler, no reactivity layer — closures don't get refreshed when the underlying state mutates. Cache the *node*, re-read the *bit* at click time:
|
|
|
|
```javascript
|
|
// Wrong — `writable` is whatever canSave returned at mount, even if
|
|
// the tree node's bit later flips to true (e.g. admin toggle reload
|
|
// re-fetched the listing).
|
|
var writable = canSave(node);
|
|
saveBtn.addEventListener('click', function () {
|
|
if (!writable) return; // STALE
|
|
});
|
|
|
|
// Right — re-read at click time.
|
|
saveBtn.addEventListener('click', function () {
|
|
if (!canSave(node)) return; // current
|
|
});
|
|
```
|
|
|
|
It's fine to use mount-time captures for *initial UI shape* (read-only banner, CodeMirror `readOnly:'nocursor'`, etc.) — those decisions are correct at the moment they're applied. The rule is specifically about gating logic in handlers that fire *after* mount.
|
|
|
|
This pattern bit twice in the markdown + YAML editors before we caught it: the typo `writable` (undefined) vs `writableMode` (captured) made every save click a no-op. Re-reading the source of truth would have surfaced the bug at click time instead of silently disabling save.
|
|
|
|
## ZDDC filename parsers
|
|
|
|
All parsing/formatting goes through `shared/zddc.js`, exposed as `window.zddc`. Tools call it directly — no per-tool wrappers.
|
|
|
|
`window.zddc` exports:
|
|
- `parseFilename(name)` → `{ trackingNumber, revision, status, title, extension, valid } | null` (extension WITHOUT leading dot)
|
|
- `parseFolder(name)` → `{ date, trackingNumber, status, title, valid } | null`
|
|
- `parseRevision(rev)` → `{ base, modifier, modifierType, modifierNumber, isDraft, modifierIsDraft, full }`
|
|
- `compareRevisions(a, b)` → number (canonical sort order)
|
|
- `formatFilename(parts)` / `formatFolder(parts)` — round-trips parsed output
|
|
- `isValidStatus(code)` — accepts known status codes plus `---`
|
|
|
|
All file objects across tools use `file.trackingNumber` (string) and `file.extension` (string, **no leading dot**, e.g. `'pdf'` not `'.pdf'`). When concatenating into a filename, write `name + '.' + ext`.
|
|
|
|
Coverage lives in `tests/zddc.spec.js` (47 cases). Add new edge cases there, not in tool tests.
|
|
|
|
## Testing quirks
|
|
|
|
- Playwright + Chromium only (File System Access API requirement)
|
|
- Tests open `dist/tool.html` via `file://` protocol — **always build before testing**
|
|
- File System Access API is mocked via `page.addInitScript()` using `tests/fixtures/mock-fs-api.js`
|
|
- Use `waitUntil: 'load'` or `'domcontentloaded'` not `'networkidle'` — bundled scripts keep the network "active"
|
|
- Archive's `#noDirectoryMessage` empty-state overlay is `position: absolute; top: 50px` — it must clear the header or it will block button clicks in tests
|
|
|
|
## ZDDC filename convention
|
|
|
|
Format: `trackingNumber_revision (status) - title.extension`
|
|
|
|
- `trackingNumber`: no spaces or underscores (e.g. `123456-EL-SPC-2623`)
|
|
- `revision`: `A`, `B`, `0`; draft prefix `~`; modifiers `+C1`, `+B1`, `+N1`, `+Q1`
|
|
- `status`: `IFA IFB IFC IFD IFI IFP IFR IFU REC RSA RSB RSC RSD RSI` or `---`
|
|
- Folder names prefix with date: `2025-10-31_trackingNumber (status) - title`
|
|
|
|
## Git workflow
|
|
|
|
- Feature-branch workflow; squash-merge feature branches to `main`
|
|
- Conventional commits: `feat(archive): ...`, `fix(transmittal): ...`
|
|
- Release tags: `<tool>-v<X.Y.Z>` per tool, all eight sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `landing-v0.0.8`, `form-v0.0.8`, `tables-v0.0.8`, `browse-v0.0.8`, `zddc-server-v0.0.8`)
|
|
- `dist/` is gitignored. Build artifacts (per-tool `dist/<tool>.html` and `dist/release-output/`) are NOT committed to this repo. Reproduce them from a tag with `./build release X.Y.Z`
|
|
- Hand-edited website content lives in a separate Codeberg repo (`codeberg.org/VARASYS/ZDDC-website`, cloned at `~/src/zddc-website/`). Source-code commits go to `main` here; content commits go to that repo
|
|
- Release artifacts live on the deploy host (`/srv/zddc/`), not in any git history. Use `./deploy` to publish
|
|
|
|
### Releasing — lockstep stable + beta snapshot
|
|
|
|
**Lockstep convention.** Every stable cut bumps all 8 artifacts (7 HTML tools + zddc-server) to the same version, even if a tool didn't change. Per-tool independent versions are gone. The coordinated next-stable target is `max(latest tag across all 8 tools) + 1` — `_coordinated_next_stable` in `shared/build-lib.sh`.
|
|
|
|
**No alpha or beta channels in the public release surface.** Simplified in May 2026 — channel mirrors (`_stable`, `_beta`, `_alpha`) and partial-version pins (`_v<X.Y>`, `_v<X>`) are gone. Each tool has exactly one canonical URL (`<tool>.html`, symlink → current stable) and a set of immutable per-version files (`<tool>_v<X.Y.Z>.html`). Same shape for zddc-server per platform.
|
|
|
|
**Storage model.** All release artifacts live on the deploy host at `/srv/zddc/releases/` (Caddy bind-mount, served as `https://zddc.varasys.io/releases/`). Locally they materialize in this repo's `dist/release-output/` (gitignored) when `./build release` runs; `./deploy` rsyncs them out. **No git history holds release artifacts** — older versions are reproducible from any `<tool>-vX.Y.Z` tag (`git checkout zddc-server-v0.0.8 && ./build release 0.0.8`). No Codeberg release assets, no LFS, no third-party mirrors.
|
|
|
|
| Artifact | Type | Layout |
|
|
|---|---|---|
|
|
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, landing, form, tables, browse |
|
|
| `<tool>.html` | symlink | canonical "current stable" URL per tool — always points at the latest cut's per-version file |
|
|
| `<tool>_v<X.Y.Z>.html.sig` | real, immutable | Ed25519 detached signature |
|
|
| `<tool>.html.sig` | symlink | canonical .sig URL (symlink → matching `.sig` of the symlinked target) |
|
|
| `zddc-server_v<X.Y.Z>_<platform>` | real binary | per-version cross-compiled binary, platform ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe} |
|
|
| `zddc-server_<platform>` | symlink | canonical "current stable" per platform |
|
|
| `zddc-server_v<X.Y.Z>_<platform>.sig` | real | matching detached signature |
|
|
| `zddc-server_<platform>.sig` | symlink | canonical .sig URL |
|
|
| `zddc-server.html` | generated stub | current-stable four-platform download page |
|
|
| `zddc-server_v<X.Y.Z>.html` | generated stub | per-version four-platform download page |
|
|
| `index.html` | regenerated by `build` | downloads landing page (version dropdown, tool cards, apps composer) |
|
|
|
|
**Single point of truth.** `./build release` is the canonical lockstep cut. It seeds `dist/release-output/` from `/srv/zddc/releases/` (only per-version immutables + `.sig` + `pubkey.pem`), forwards each HTML tool's build with the agreed version, calls `promote_zddc_server` (in `shared/build-lib.sh`) to copy the freshly cross-compiled binaries with their canonical symlinks, then `write_zddc_server_stubs_all` regenerates stub pages, then `sign_release_artifacts` produces `.sig` for every new per-version file, then `build_releases_index` rewrites the downloads page. **Then** the top-level build folds the regenerated `zddc/internal/apps/embedded/*` files into a `release: vX.Y.Z lockstep` commit and tags all 8 artifacts at that commit. `./deploy --releases` publishes the bundle.
|
|
|
|
- **Stable** (`./build release` or `--release X.Y.Z`): Writes per-version HTML for the seven HTML tools + per-version binaries for zddc-server (real bytes, immutable) + canonical `<tool>.html` and `zddc-server_<platform>` symlinks. Updates `zddc/internal/apps/embedded/*` to stable-labeled bytes, makes a release commit, tags all 8 (`<tool>-v<X.Y.Z>`) **at that commit** so binaries built from the tag embed clean stable bytes.
|
|
- **Beta** (`./build beta`): Internal SHA snapshot for the BMC dev chart pipeline. Regenerates `zddc/internal/apps/embedded/*` with beta-labeled bytes and makes a `chore(embedded): cut v<X.Y.Z>-beta` commit. **NO public artifact in `dist/release-output/`.** The chart's appVersion gets set to `"<X.Y.Z>-beta-<sha>"`; chart's Dockerfile parses the suffix and `git fetch`-es that SHA. The chart compiles its own binary from the fetched source — the binary's embedded HTML tools are whatever this commit wrote. No tag.
|
|
- **Plain dev builds** (`./build` with no arg): produce `tool/dist/<tool>.html` for HTML tools and `zddc/dist/zddc-server-<platform>` binaries; do NOT touch `dist/release-output/`, the live site, or `embedded/`. Use it to iterate without affecting deployable state.
|
|
|
|
**Bake-in invariant** — what zddc-server's binary embeds via `//go:embed` from `zddc/internal/apps/embedded/`:
|
|
|
|
| Image | Chart pin | Embeds |
|
|
|---|---|---|
|
|
| Prod (Dockerfile.prod, BMCD) | `appVersion: "X.Y.Z"` → tag `zddc-server-v<X.Y.Z>` | Stable-labeled bytes from the tagged release commit |
|
|
| Dev (Dockerfile, devshell) | `appVersion: "X.Y.Z"` or `"X.Y.Z-beta-<sha>"` → tag or SHA | Stable or beta-snapshot bytes (whichever the chart points at) |
|
|
| Local dev iteration | n/a | Use `tool/dist/<tool>.html` directly; binary's embedded copy lags |
|
|
|
|
On-page `{{BUILD_LABEL}}` format (HTML tools only — zddc-server's version comes from the binary itself):
|
|
|
|
- Plain dev: `vX.Y.Z-dev · <full-ts> · <sha>[-dirty]` (red), where X.Y.Z is the next-stable target.
|
|
- `./build beta`: `vX.Y.Z-beta · <full-ts> · <sha>` (red). Only seen on the dev chart's compiled binary.
|
|
- `./build release [X.Y.Z]`: `v<X.Y.Z>` (black).
|
|
|
|
After cutting a stable release, `git push origin main && git push origin --tags` to publish the new release commit + every per-tool tag in lockstep.
|
|
|
|
### Release discipline (MUST rules)
|
|
|
|
The build enforces lockstep mechanically (one command bumps all 8). The rules below are still on you.
|
|
|
|
1. **Stable doesn't regress.** No known-broken features that worked in the previous stable. If `v0.0.5` ships with a bug, the path forward is `v0.0.6` with a fix — never edit a previously-published per-version file in place. Per-version files are immutable.
|
|
2. **Lockstep is the contract.** Don't cut a single tool's stable without bumping the rest. The HTML tool's standalone `sh tool/build.sh --release X.Y.Z` flag still exists as an escape hatch but emits a tag that immediately drifts out of sync with the others.
|
|
3. **No backports.** Always cut a new stable at a higher version. Users pinned to an old version stay pinned by choice.
|
|
4. **Beta is internal.** Don't advertise `./build beta` snapshots to users — they're a BMC dev pipeline plumbing concept, not a "preview" release. The canonical URL `<tool>.html` always points at the latest stable.
|
|
5. **Hotfix path.** For critical bugs: fix on `main`, cut a new stable. Tag the commit message `fix:` or include "hotfix" so intent is visible in `git log`.
|
|
6. **Beta soak before promoting (recommended).** Give a beta-snapshotted build a few days on the dev chart before cutting the same code as stable. Not enforced; use judgment for trivial changes.
|
|
|
|
### Install model
|
|
|
|
No install script. Two paths:
|
|
|
|
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
|
|
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive/<party>/incoming/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. **Project shape (May 2026 reshape):** `archive/` is the only physical project-root directory. Six top-level URLs are virtual aggregators: `ssr/mdl/rsk` (tables row-rollups across parties, with a synthesised `$party` source-party column the tables tool renders read-only and strips before write) and `working/staging/reviewing` (browse folder-nav listings of parties with non-empty content; per-party URLs 302-redirect to `archive/<party>/<slot>/`). Mkdir directly at the project root is restricted to `archive` and `_`/`.`-prefixed system names — virtual aggregator names and ad-hoc folders return 409. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
|
|
|
|
To override at any level, either:
|
|
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
|
|
2. Write an `apps:` entry in any `.zddc` along the path. Spec is one of `stable` (canonical "latest stable"), `v0.0.4` (exact-version pin), full URL, or local path. Closer-to-leaf entries win. (Or change `default_tool` / `dir_tool` / `available_tools` to route a different tool entirely.)
|
|
|
|
URL sources fetch once and cache forever in `<ZDDC_ROOT>/_app/<host>/<path>`. To force a re-fetch, delete the cache file. No background refresh, no SHA-256 verification, no admin UI. If a configured URL fetch fails, the server falls back to the embedded copy and emits a one-time WARN log.
|
|
|
|
Operators audit by reading the `X-ZDDC-Source` response header: `fetch:URL` / `cache:URL` / `path:/abs` / `embedded:<app>@<build>`. Direct URL access to `/_app/...` is blocked at the dispatch layer.
|
|
|
|
**Runtime mode detection** in archive is independent of install: it auto-detects multi-project / project-root / in-archive from `?projects=` plus folder shape. The other tools don't care where they live.
|
|
|
|
### Worktrees
|
|
|
|
Use `git worktree` to run multiple agents on separate branches simultaneously without filesystem collisions.
|
|
|
|
- Worktrees live at `~/src/zddc-<branch-name>` (sibling of the main clone)
|
|
- Before starting work on a feature branch, check `git worktree list`; if no worktree exists, create one: `git worktree add ~/src/zddc-<branch-name> -b <branch-name>`
|
|
- All edits, builds (`./build`), and tests (`npm test`) run from within the worktree directory — build scripts use relative paths so this works correctly
|
|
- The `dist/` force-commit rule (`git add -f`) applies per-worktree
|
|
- After the branch is merged, clean up: `git worktree remove ~/src/zddc-<branch-name>` then delete the branch
|
|
- Never run `git checkout` or `git switch` inside a worktree that another agent may be using
|
|
|
|
## Transmittal-specific
|
|
|
|
- Two-phase hydration: `populateStatic()` before publish, `hydrate()` on load of published file
|
|
- Reactive state via Proxy — `app.state.mode = 'view'` auto-notifies subscribers
|
|
- No runtime CDN loads. Every vendor library (jszip, docx-preview, xlsx, UTIF, Toast UI) is bundled at build time via `concat_files`. The dist HTML is fully self-contained — "ship the record player with the record."
|
|
- Published payload stored in `<script id="transmittal-data" type="application/json">`
|
|
|
|
## Markdown editor (inside browse)
|
|
|
|
The markdown editor lives at `browse/js/preview-markdown.js` and is mounted as the preview plugin for `.md`/`.markdown` files by `browse/js/preview.js`. The standalone `mdedit/` tool has been retired — `browse` is the editor.
|
|
|
|
- Toast UI Editor v3.2.2 is vendored at `shared/vendor/toastui-editor-all.min.js` and concatenated into `browse/dist/browse.html` at build time. No runtime CDN.
|
|
- YAML front matter (`---\n…\n---`) is split off on load and edited in a dedicated `<textarea>` in the sidebar; on save it's recombined onto the body. Always present (no "empty pane") so authoring new FM is a single click.
|
|
- In server mode (HTTP-backed file handles), three Download buttons appear in the file header — DOCX/HTML/PDF — fetching `?convert=<fmt>` and triggering a browser download. The buttons auto-save the dirty buffer first so the converted bytes reflect what's on screen.
|
|
|
|
## Server-side document conversion (`zddc/internal/convert`)
|
|
|
|
zddc-server can convert `.md` → DOCX/HTML/PDF on demand at `GET /<path>/foo.md?convert=docx|html|pdf`.
|
|
|
|
**Architecture.** zddc-server's Go code does the bare minimum: it `exec.Command("pandoc", args...)` or `exec.Command("chromium-browser", args...)`. **The sandbox + resource caps live in the IMAGE**, not in Go. In the production runtime image (`zddc/runtime.Containerfile`), `/usr/local/bin/pandoc` and `/usr/local/bin/chromium-browser` are symlinks to `zddc-sandbox-exec` — a shell wrapper that:
|
|
|
|
1. Creates a transient cgroup v2 (memory + pids cap from `ZDDC_CONV_MEM_MAX` / `ZDDC_CONV_PIDS_MAX` env), moves itself in.
|
|
2. Wraps the real binary at `/usr/bin/<name>` in a bubblewrap sandbox (`--unshare-all --unshare-user-try --die-with-parent --ro-bind /usr /usr ... --proc /proc --dev /dev --tmpfs /tmp --clearenv`).
|
|
3. exec's `/usr/bin/<name>` with the original argv.
|
|
|
|
Why this shape: swapping isolation strategies (firejail, systemd-nspawn, podman-run, raw exec for dev) is purely an image concern. The Go code never changed. A separate `zddc-cgroup-init` script runs at container start to delegate cgroup v2 `subtree_control` (the "no internal processes" constraint), then exec's zddc-server. Both scripts live in `zddc/runtime/`.
|
|
|
|
**Outer-container privileges.** Nested bwrap needs the outer container to permit user + mount namespace creation. Pod Security Standards defaults block this. The helm chart sets `securityContext: capabilities.add: [SYS_ADMIN]`, `seccompProfile.type: Unconfined`, `appArmorProfile.type: Unconfined`. Trade-off: a zddc-server RCE has near-root power within the container's namespace, but the bind-mount layout (overlay fs, no host /home or /usr visible) still bounds the blast radius. The per-conversion bwrap sandbox is the real isolation boundary between zddc-server and untrusted pandoc/chromium.
|
|
|
|
**Config knobs** (all in `cmd/zddc-server`):
|
|
- `--convert-pandoc-binary` (default `pandoc`) / `--convert-chromium-binary` (default `chromium-browser`; `chromium` on debian)
|
|
- `--convert-scratch-dir` (default `$TMPDIR`) — host scratch root; the wrapper bind-mounts the per-call subdir
|
|
- `--convert-mem-mib` (default 1024) → wrapper's `memory.max`
|
|
- `--convert-pids` (default 256) → wrapper's `pids.max`
|
|
- `--convert-timeout` (default 60s) → enforced in Go via `context.WithTimeout`
|
|
|
|
**Other plumbing.**
|
|
- I/O via stdin/stdout + scratch dir. Pandoc reads markdown from stdin, writes to stdout. Templates + intermediate HTML + output PDF live in a per-call subdir under the scratch root; the dir's host path is passed to the child via `ZDDC_SCRATCH` so the wrapper bind-mounts it into the sandbox at the same path (no path translation).
|
|
- Output cached at `<dir>/.converted/<base>.<ext>` (hidden by the `.` prefix). mtime synced to source so the fast path is a stat-and-serve with no exec. PUT/DELETE/MOVE on the source `.md` purges the sidecars.
|
|
- Per-project template variables (client/project/contractor/project_number) come from `.zddc` `convert:` cascade keys. Title/tracking_number/revision/status are derived from the filename via `zddc.ParseFilename`.
|
|
- If pandoc/chromium aren't on PATH (operator running zddc-server outside the runtime image), the endpoint serves 503 with a Retry-After. The rest of the server keeps working. Operators who run zddc-server with raw pandoc/chromium (no wrapper) get a working but unsandboxed conversion endpoint — useful for dev iteration.
|
|
|
|
## Form-data system (`form/` + zddc-server form handler)
|
|
|
|
A schema-driven form renderer used to collect structured data into YAML files in the file tree. The form tool (`form/`) is the renderer; the server-side endpoints live in `zddc/internal/handler/formhandler.go`; the validator is `zddc/internal/jsonschema/`.
|
|
|
|
**Form spec**: `<name>.form.yaml` — top-level envelope is `{title, description, schema, ui, mode}`. `schema` is JSON Schema 2020-12 (subset; see "Validator subset" below). `ui` is RJSF-style (`ui:widget`, `ui:order`, `ui:autofocus`, `ui:placeholder`, `ui:help`, `ui:readonly`, `ui:options.{addable,removable}`). LLMs author this dialect well.
|
|
|
|
**URL conventions** (form posts back to its own URL; server strips `.html`). The spec lives **inside** the rows-dir alongside the row YAMLs, so the whole form (spec + every submission) is a single self-contained directory:
|
|
- `GET /<dir>/form.html` — render empty form
|
|
- `POST /<dir>/form.html` — create new submission → 201 + Location capability URL pointing at the new `<dir>/<id>.yaml`
|
|
- `GET /<dir>/<id>.yaml.html` — render form pre-filled from `<id>.yaml`
|
|
- `POST /<dir>/<id>.yaml.html` — overwrite that submission → 200
|
|
|
|
**Storage**: spec at `<dir>/form.yaml`. Submission filenames depend on whether the directory has a cascade-declared `records:` rule (see "Records, audit, and history" below):
|
|
- **No matching `records:` rule** — submissions land at `<dir>/<YYYY-MM-DD>-<email-sanitized>.yaml` (the legacy date+email scheme; still the path for ad-hoc operator-defined forms).
|
|
- **Matching `records:` rule** (mdl/rsk/ssr and operator-declared records) — filename is composed from body fields via the rule's `filename_format`; for rsk-style rules the server also auto-assigns a per-row sequence within the table-scope group.
|
|
|
|
Copying `<dir>` elsewhere copies the spec plus every submission together. ACL applies via the existing `.zddc` cascade.
|
|
|
|
**Round-trip**: v0 is form-as-truth — submission YAML is regenerated from form state on every save; comments in submissions are not preserved. File-as-truth mode (lossless YAML round-trip via the eemeli/yaml Document API) is a v1 feature, needed for hand-edited files like `.zddc` itself.
|
|
|
|
**Validator subset** (`zddc/internal/jsonschema/`): `type` (string/number/integer/boolean/array/object), `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, `required`, `additionalProperties: false`, `properties`, `items`, `format` (`date`, `email`). Schema also carries three client-facing extensions that survive round-trip but aren't enforced by the validator (the server enforces them via cascade or strip-on-write): `readOnly: true` (UI renders disabled), `x-labels: { code → label }` (paired display text for enum dropdowns). NOT supported in v0: `$ref`, `$defs`, `if/then/else`, `oneOf`/`anyOf`/`allOf`, conditional visibility. The form-spec meta-schema enforces that authors stay in the supported subset.
|
|
|
|
**Renderer subset** (`form/js/`): types listed above, enum (select / `ui:widget: radio`), `format: date|email`, textarea, nested objects, arrays of primitives, arrays of objects with add/remove rows. `ui:show-when` and reorder are v1.
|
|
|
|
**Adding a new form**: create a directory `<dir>/` and drop `form.yaml` into it (per `.zddc` ACL). No code change required. Visit `<dir>/form.html`.
|
|
|
|
## Tables system (`tables/` + zddc-server table handler)
|
|
|
|
Read/aggregate counterpart to the form system. Renders a directory of YAML row files as a sortable, filterable table; each row clicks through to its `<id>.yaml.html` form editor. The tables tool (`tables/`) is the renderer; the server-side recognizer is `zddc/internal/handler/tablehandler.go RecognizeTableRequest`.
|
|
|
|
**Discovery is presence-based**, the same convention as forms: a `<dir>/table.yaml` on disk auto-mounts at `<dir>/table.html`. The directory is the table.
|
|
|
|
**Storage** (self-contained directory):
|
|
|
|
```
|
|
<dir>/
|
|
table.yaml ← spec
|
|
form.yaml ← row-edit form (paired with table.yaml)
|
|
<id>.yaml ... ← rows
|
|
```
|
|
|
|
`table.yaml` and `form.yaml` are excluded from the rows list. Each row is also a form submission — the same files the form system reads — so the table view and the per-row form editor are two views of one folder of YAMLs. Copying `<dir>/` elsewhere copies the entire table (spec + form + every row) — that's the whole point of the in-dir layout.
|
|
|
|
**One table per directory** by construction (the spec is the singleton `table.yaml`). No `.zddc` reference needed; presence-based discovery is the entire rule. To make a directory a table, drop a `table.yaml` in it — that's it.
|
|
|
|
**Subfolders inside a table dir are allowed and silently ignored as rows.** The rows iterator filters non-`.yaml` entries, so directories don't show up in the table view. Legitimate subfolder use cases:
|
|
- **Nested sub-tables** — `<dir>/sub-list/table.yaml` is its own self-contained table at `<dir>/sub-list/table.html`. Composition, not violation.
|
|
- **Per-row attachments** — `<dir>/<id>.attachments/file.pdf`. Natural sidecar pattern; the row YAML can reference its attachments by relative path.
|
|
- **Drafts / staging** — `<dir>/.drafts/<id>.yaml` (dot-prefix → hidden from listings as well as from the table).
|
|
- **Per-row history** — `<dir>/.history/<base-without-ext>/<RFC3339Nano>-<sha8>.yaml`. Server-managed; one directory per record, one file per archived revision. See "Records, audit, and history" below.
|
|
|
|
**Default-MDL fallback at `archive/<party>/mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive/<party>/mdl/`, presence-based discovery is the rule.
|
|
|
|
**Default-MDL columns mirror the tracking-number components** documented at `zddc.varasys.io/reference.html#tracking-numbers`: `originator`, `phase`, `project`, `area`, `discipline`, `type`, `sequence`, `suffix` — each one a slot of the deliverable's permanent identifier — plus `title`, `plannedRevision`, `plannedDate`, `status`, `owner`. The form schema accepts free-text on every component by default. Projects narrow the vocabulary via the cascade's `field_codes:` (see below) without rewriting the schema — operator overrides at `archive/<party>/mdl/{table,form}.yaml` still win atomically over the embedded defaults. Source: `zddc/internal/handler/default-mdl.{table,form}.yaml`.
|
|
|
|
**Adding a new table**: create a directory `<dir>/` and drop `table.yaml` (and optionally `form.yaml` for row editing) into it. No code change required. Visit `<dir>/table.html`.
|
|
|
|
## Records, audit, and history
|
|
|
|
The "records" subset of the tables system carries three guarantees the generic form/table flow doesn't: server-stamped audit fields, immutable per-record history, and cascade-driven filename composition. The mechanism lives in `zddc/internal/handler/history.go` (`WriteWithHistory`) and `zddc/internal/zddc/field_codes.go`. Three record types ship out of the box:
|
|
|
|
| Type | Folder | Filename | Identity carrier |
|
|
|---|---|---|---|
|
|
| **MDL** (deliverables) | `archive/<party>/mdl/` (many siblings) | Composed tracking number, e.g. `ACM-PRJ-EL-SPC-0001.yaml` | Body's component fields |
|
|
| **RSK** (risk register) | `archive/<party>/rsk/` (many siblings, multiple tables) | `<table-tracking>-<row>.yaml`, e.g. `ACM-PRJ-EL-RSK-0001-001.yaml` | Body's components + server-assigned row sequence |
|
|
| **SSR** (parties register) | `archive/<party>/ssr.yaml` (one per party folder) | Always literal `ssr.yaml` | Parent folder name (existing `name` strip/inject in `ssrhandler.go`) |
|
|
|
|
**Two new `.zddc` keys** carry the rules (see `zddc/internal/zddc/file.go` + `field_codes.go`):
|
|
|
|
- `field_codes:` — vocabulary for the components used in filename composition and constrained body fields. Each entry is a discriminated union over `kind: enum|pattern|free` (`{kind: enum, codes: {ACM: Acme Inc, …}}` / `{kind: pattern, pattern: "^[0-9]{4}$"}` / `{kind: free, description: "..."}`). Map-merge across the cascade (mirror of `apps:`) — a deeper level can narrow or replace a single code's vocabulary without dropping unrelated codes.
|
|
- `records:` — per-pattern rules keyed by filename basename (literal `ssr.yaml` or glob `*.yaml`). Each entry carries `filename_format` (composition template with `{field}` and `{field?}` placeholders), `field_defaults`, `locked`, plus `row_field` + `row_scope_fields` for RSK-style tables-of-rows. Filename-pattern scoping is what lets the SSR rule live at the party-folder level without affecting `mdl/`, `rsk/`, `received/`, etc., siblings.
|
|
|
|
Defaults are baked into `defaults.zddc.yaml`; `field_codes:` ships empty (every deployment writes its own vocabulary).
|
|
|
|
**Six server-managed audit fields** are injected on every write and stripped from incoming bodies before validation (snake_case to match `.zddc`'s existing `created_by:`):
|
|
- `created_at`, `created_by` — stamped on create; preserved untouched on every update
|
|
- `updated_at`, `updated_by` — refreshed on every write
|
|
- `revision` — `1` on create, `+1` per update
|
|
- `previous_sha` — first 8 hex chars of SHA-256 of the prior revision's bytes; absent on create. Forms a hash chain for tamper evidence
|
|
|
|
**History layout**: for any record at `<dir>/<base>.<ext>`, the prior version is archived at `<dir>/.history/<base>/<RFC3339Nano-UTC>-<sha8>.<ext>` before the live file is overwritten. Per-record subfolder under `.history/` keeps `readdir` cheap and makes party-folder rename move SSR history along atomically (the dot-folder is inside the party folder, so `os.Rename` carries it).
|
|
|
|
**Write ordering**: history first, then live. A crash between the two leaves the prior version safely archived; the retry is idempotent because the history filename is deterministic (timestamp + sha of prior bytes).
|
|
|
|
**Strip-and-stamp policy**: clients can't forge audit fields. `WriteWithHistory` strips all six keys from the incoming body BEFORE schema validation runs, then injects authoritative values from request context. A client that sends `created_by: eve@evil` finds it silently overwritten with the request principal.
|
|
|
|
**Wire surface**:
|
|
- `PUT /<record>.yaml` — routed through `WriteWithHistory` automatically when the basename matches a `records:` rule. Response echoes the stamped YAML as the body (Content-Type: application/yaml) so the tables client can update its row state without a re-GET.
|
|
- `GET /<record>.yaml?history=1` — JSON list of prior revisions: `[{revision, ts, by, sha, path}, …]`. ACL gates against the live record (read it → read its history).
|
|
|
|
**Record-vs-config distinction**: `WriteWithHistory` fires only for genuine record paths. The gate (`isRecordPath` in `fileapi.go`) excludes `table.yaml`, `form.yaml`, `.zddc`, and the spec naming variants `*.table.yaml` / `*.form.yaml`. Those bypass audit stamping (they're configuration, not data) and go through plain `WriteAtomic`.
|
|
|
|
**Operator customization**:
|
|
- To narrow a deployment's originator codes: write `field_codes: originator: {kind: enum, codes: {ACM: …, BET: …}}` at the project root `.zddc`.
|
|
- To add a new table type: declare a `records:` entry under the appropriate `paths:` level (or a sibling `.zddc` in the folder) with a `filename_format` referencing fields the body carries.
|
|
- To inspect a record's revision history: `curl https://<host>/<path>.yaml?history=1 -H 'Authorization: Bearer …'`.
|
|
|
|
Source: `zddc/internal/handler/history.go`, `zddc/internal/zddc/field_codes.go`, `zddc/internal/zddc/walker.go`, `zddc/internal/zddc/cascade.go`, `zddc/internal/zddc/defaults.zddc.yaml`. Tests: `zddc/internal/handler/history_test.go`.
|
|
|
|
## Implementation-vs-dependency policy
|
|
|
|
Match implementation cost to actual surface used. Reimplement focused subsets when a dep's surface area is much larger than what we consume; adopt for genuinely large specs (YAML parsing, etc.) where reimplementing is foolish. Examples in this codebase:
|
|
|
|
- **`zddc/internal/jsonschema/`** — focused 2020-12 validator (~300 LoC) covering only the v0 form-spec subset. A full library (e.g. `santhosh-tekuri/jsonschema/v6`) brings 70%+ surface we don't use.
|
|
- **`gopkg.in/yaml.v3`** — adopted as a dep. Reimplementing YAML is foolish.
|
|
|
|
This is a guideline, not a rule. Revisit per-feature: when v1+ form-spec adds `$ref` + `oneOf` + `if/then/else`, the validator's "savings" evaporate and adopting becomes cheaper.
|
|
|
|
## zddc-server
|
|
|
|
Go HTTP server sub-project living at `zddc/`. Replaces `caddy file-server --browse` for ZDDC archives.
|
|
|
|
### Bootstrap config (REQUIRED — unlocks the server)
|
|
|
|
zddc-server grants no access to anyone until two operator files are populated. The embedded `defaults.zddc.yaml` ships with empty role members and references those roles throughout its cascade, so a fresh deployment refuses every request until the operator opts in. `zddc-server` logs a startup warning (see `warnIfNoBootstrap` in `zddc/cmd/zddc-server/main.go`) when the root `.zddc` grants nobody anything — skipped under `--no-auth`.
|
|
|
|
**Root `<ZDDC_ROOT>/.zddc`** — at minimum, declare an admin:
|
|
|
|
```yaml
|
|
admins:
|
|
- cwitt@burnsmcd.com
|
|
```
|
|
|
|
`admins:` is honored only at the root (subdir admins are read but ignored by `IsAdmin`, see `zddc/internal/zddc/file.go:109-112`). Admins are sudo-style — powers gate on the `zddc-elevate=1` cookie or implicit bearer-token elevation.
|
|
|
|
**Per-project `<project>/.zddc`** — populate role members:
|
|
|
|
```yaml
|
|
title: "Project Phoenix"
|
|
roles:
|
|
document_controller:
|
|
members:
|
|
- dc1@burnsmcd.com
|
|
project_team:
|
|
members:
|
|
- alice@burnsmcd.com
|
|
- '*@acme.com'
|
|
```
|
|
|
|
The embedded cascade already grants `project_team: r` project-wide and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, subtree-admin of every `archive/<party>/` so they own each party's lifecycle slots — `working/`, `staging/`, `reviewing/`, `incoming/`). Populating role members lights all of that up.
|
|
|
|
**Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`):
|
|
|
|
- `acl: { permissions: { <principal>: <bits> }, inherit: <bool>? }` — there is no `allow:` key; an `allow:` block parses cleanly but is silently dropped during unmarshal. Real footgun — easy to write `acl: { allow: [...] }` and assume it works.
|
|
- Bits: any subset of `r w c d a` (read / write / create / delete / admin); empty string is an explicit deny.
|
|
- Principals: email (must contain `@`), glob (`*@domain.com`), or role name (no `@`).
|
|
- `roles: { <name>: { members: [...], reset: <bool>? } }` — members union across the cascade unless `reset: true`.
|
|
- `admins: [<email>, ...]` — root only; sudo-style elevation per request.
|
|
- `title:` — read only from the per-project `.zddc`; surfaces on the landing-page picker.
|
|
|
|
Run `zddc-server show-defaults` to dump the embedded `defaults.zddc.yaml` with annotated comments — that's the full schema with all the cascade keys (`worm:`, `auto_own:`, `drop_target:`, `apps:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.).
|
|
|
|
### Build
|
|
|
|
zddc-server ships as a cross-compiled binary, not a container image. There's no Containerfile or compose file in this repo (the chart Dockerfiles compile from source at deploy time at the right tag).
|
|
|
|
```sh
|
|
# Compile a local binary for the host platform via the build image.
|
|
# Same flag pattern as Test below — see that subsection for why.
|
|
podman run --rm --network=host -v "$PWD":/src:Z -v /tmp/gocache:/root/go/pkg/mod:Z \
|
|
-w /src/zddc -e GOPROXY=https://proxy.golang.org -e GOSUMDB=off -e GOPRIVATE= \
|
|
localhost/zddc-go:1.24 go build -o zddc-server ./cmd/zddc-server
|
|
|
|
# `go run` is normally a one-liner but in-container `go run` of a network
|
|
# server is awkward — for dev iteration, build with the line above and
|
|
# launch the binary on the host (`./zddc/zddc-server`).
|
|
```
|
|
|
|
The repo's top-level `./build` cross-compiles the four release binaries (linux/amd64, darwin/amd64, darwin/arm64, windows/amd64) into `zddc/dist/` via a containerized Go toolchain (podman or docker). On `./build release` it also promotes those binaries to `dist/release-output/` with their per-platform canonical symlinks + stub pages — same lockstep flow as the HTML tools. `./deploy` rsyncs the bundle to `/srv/zddc/releases/`.
|
|
|
|
### Test
|
|
|
|
Go is **not** installed on the dev shell host directly — run `go test` (and `go build`) through a golang-alpine image. `./build`'s containerized cross-compile already pulls `golang:1.24-alpine` as a build stage; tag it for reuse:
|
|
|
|
```sh
|
|
# One-time: locate the golang-alpine image (~810 MB, untagged after a build run)
|
|
# and give it a stable name. The size and `golang/.../go test` lines distinguish
|
|
# it from the small ~18 MB zddc-server runtime image.
|
|
podman images --filter dangling=true --format '{{.Size}}\t{{.ID}}' | sort -h | tail
|
|
podman tag <id> localhost/zddc-go:1.24
|
|
|
|
# If you have no <none> 810 MB image (fresh machine), pull directly:
|
|
podman pull docker.io/library/golang:1.24-alpine
|
|
podman tag docker.io/library/golang:1.24-alpine localhost/zddc-go:1.24
|
|
```
|
|
|
|
Canonical invocation:
|
|
|
|
```sh
|
|
podman run --rm --network=host \
|
|
-v "$PWD":/src:Z \
|
|
-v /tmp/gocache:/root/go/pkg/mod:Z \
|
|
-w /src/zddc \
|
|
-e GOPROXY=https://proxy.golang.org \
|
|
-e GOSUMDB=off \
|
|
-e GOPRIVATE= \
|
|
localhost/zddc-go:1.24 \
|
|
go test ./...
|
|
```
|
|
|
|
Why each flag:
|
|
- `--network=host` — the alpine image's TLS chain can't shake hands with `gopkg.in` directly (sandbox hits "Connection reset by peer"); host networking + the proxy below works around it.
|
|
- `GOPROXY=https://proxy.golang.org` — fetch via the public proxy. Without this, the build-image's baked `GOPRIVATE=*` forces direct VCS, which fails on `gopkg.in/natefinch/lumberjack.v2`.
|
|
- `GOSUMDB=off` — sum.golang.org isn't reachable from the sandbox either; we already trust the proxy.
|
|
- `GOPRIVATE=` (empty) — explicit override of the image's `GOPRIVATE=*`, which is a leftover from how `./build` does in-container compilation and would otherwise re-trigger direct fetch.
|
|
- `/tmp/gocache` mount — persistent module cache across runs.
|
|
|
|
Run-it-once-per-session pattern: alias it. Do **not** `apt install golang` on the host — the image is the source of truth for the version pin, so dev and CI compile against the same Go.
|
|
|
|
### Run (development)
|
|
|
|
```sh
|
|
ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
|
|
go run ./cmd/zddc-server
|
|
```
|
|
|
|
For a release binary downloaded from `zddc.varasys.io/releases/`:
|
|
|
|
```sh
|
|
curl -O https://zddc.varasys.io/releases/zddc-server_stable_linux-amd64
|
|
chmod +x zddc-server_stable_linux-amd64
|
|
ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
|
|
./zddc-server_stable_linux-amd64
|
|
```
|
|
|
|
### Key environment variables
|
|
|
|
| Variable | Default | Purpose |
|
|
|---|---|---|
|
|
| `ZDDC_ROOT` | *(required)* | Path to served file tree |
|
|
| `ZDDC_ADDR` | `:8443` | Bind address |
|
|
| `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | Header set by upstream proxy with user email (oauth2-proxy / nginx auth-request convention) |
|
|
| `ZDDC_INDEX_PATH` | `.archive` | Virtual archive index URL segment |
|
|
| `ZDDC_LOG_LEVEL` | `info` | Logging verbosity |
|
|
| `ZDDC_CORS_ORIGIN` | *(empty)* | Comma-separated CORS allowlist; empty (default) disables CORS — appropriate for embedded-tools deployments where tools and data are same-origin. Set explicitly only for self-hosted tools at a different host (e.g. `https://tools.acme.com`) or the CDN-bootstrap pattern (`https://zddc.varasys.io`). |
|
|
| `ZDDC_INSECURE` | *(empty)* | Must be `1` to allow startup with no `<ZDDC_ROOT>/.zddc`. Without it, the server refuses to start because no `.zddc` files anywhere → public-by-default. Set only for deliberately-public archives. |
|
|
| `ZDDC_NO_AUTH` | *(empty)* | `1` skips ACL enforcement entirely on this instance. On a master: anyone reads everything (dev / trusted-LAN read-only deployments). On a downstream proxy/cache/mirror: trust upstream's filtering, don't re-evaluate ACLs locally. **Distinct from `ZDDC_INSECURE`** (which gates a startup safety check). |
|
|
| `ZDDC_UPSTREAM` | *(empty)* | Master URL (`https://master.example.com`). When set, the binary runs as a **client** (downstream proxy/cache/mirror) instead of a master — the master-side machinery (archive index, apps server, watcher, OPA, ACL middleware, token store) is replaced by the cache layer in `zddc/internal/cache/`. `--root` becomes the cache directory. **Setting this also downgrades the `--addr` default to `127.0.0.1:8443` (loopback)** — the cache forwards a bearer to upstream without authenticating the local caller, so non-loopback binds with `ZDDC_BEARER_FILE` set are refused unless `ZDDC_INSECURE_DIRECT=1` is also set. |
|
|
| `ZDDC_MODE` | `cache` | Client mode: `proxy` (forward live, no persistence), `cache` (default; persist responses on access), `mirror` (phase 3 — currently behaves like `cache`). Ignored when `ZDDC_UPSTREAM` is empty. |
|
|
| `ZDDC_BEARER_FILE` | *(empty)* | Path to a 0600 file containing the master-issued token (see `/.tokens` on the master). Forwarded as `Authorization: Bearer …` to upstream on every request. Ignored when `ZDDC_UPSTREAM` is empty. |
|
|
| `ZDDC_SKIP_TLS_VERIFY` | *(empty)* | `1` accepts self-signed / untrusted upstream certs. Distinct from `ZDDC_NO_AUTH`. Dev / internal-CA scenarios only. |
|
|
| `ZDDC_MIRROR_SUBTREE` | *(empty)* | Comma-separated URL subtrees the access-triggered mirror walker keeps current (e.g. `/Vendors/Acme,/Public`). Empty + `ZDDC_MODE=mirror` = full mirror (`/`). Ignored when `ZDDC_MODE != mirror`. |
|
|
| `ZDDC_MIRROR_MIN_INTERVAL` | `1h` | Minimum gap between walks of the same mirror subtree. Idle subtrees generate zero upstream traffic until next access. Format is Go `time.ParseDuration`. |
|
|
| `ZDDC_OPA_URL` | `internal` | Policy decider endpoint. `internal` (default) = in-process Go evaluator (same `.zddc` cascade we always had). `http(s)://...` or `unix:///...` = external OPA — every access decision becomes a `POST /v1/data/zddc/access/allow` to the configured endpoint. Federal customers with their own audited Rego use this; commercial deployments leave it `internal`. |
|
|
| `ZDDC_OPA_FAIL_OPEN` | *(empty)* | External OPA only. `1` = allow on transport error; default = fail closed (deny). |
|
|
| `ZDDC_OPA_CACHE_TTL` | `1s` | External OPA only. Per-decision cache TTL — amortizes round-trips on bursty patterns (e.g. `.archive` listings hit the same `(email, dir)` tuple many times). `0` disables. Format is Go `time.ParseDuration`. |
|
|
| `ZDDC_APPS_PUBKEY` | *(empty)* | Path to PEM Ed25519 pubkey for verifying signatures on URL-fetched `apps:` artifacts. Empty = URL apps refused. Download from `zddc.varasys.io/pubkey.pem` (canonical channels) or supply your own. No baked-in default — same posture as TLS certs. Alternative inline form: `apps_pubkey:` in root `.zddc` (root-only, env/flag wins). |
|
|
| `ZDDC_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` | JSON-line audit log (lumberjack-rotated, 100 MB / 10 backups / 90 days, gzipped). Server auto-mkdirs the parent. Set explicitly to empty (`--access-log=`) to disable. Per-host filename + `host` field in every record so multi-replica deployments writing to the same `.zddc.d/` dir disambiguate cleanly. |
|
|
|
|
### URL handling
|
|
|
|
**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.
|
|
|
|
**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.
|
|
|
|
**`.zip` files are navigable directories.** `GET …/Foo.zip/` → JSON listing of the zip's members (or browse HTML); `GET …/Foo.zip/sub/doc.pdf` → that one member, extracted and streamed (Range/ETag via `http.ServeContent`); `GET …/Foo.zip` (no slash) → the raw `.zip` download, unchanged. Write methods to a path inside a `.zip` → 405 (read-only). ACL = the chain of the directory *containing* the zip (a zip has no `.zddc`, like `.archive`). Code: `internal/zipfs` (member listing/extraction with a zip-slip guard) + `handler.ServeZip`, routed by `splitZipPath` in `dispatch` *before* the file-API branch (gated by a cheap `.zip/` substring check so ordinary requests don't pay an `os.Stat`-per-segment walk). Client-side, `shared/zip-source.js` (`ZipDirectoryHandle`/`ZipFileHandle` over JSZip) gives the archive and browse tools the same navigation offline. Archive treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder (`isTransmittalFolderZip` in `archive/js/parser.js`); browse expands any `.zip`. Nested zips: the server serves one level (`…/Foo.zip/inner.zip` is the inner zip's bytes; `…/Foo.zip/inner.zip/` isn't a listing) — clients that need deeper nesting fetch the inner zip whole and recurse with JSZip.
|
|
|
|
**`GET /dir/?zip=1` — subtree download.** Streams an `application/zip` of every readable file under `/dir/` (recursively), `Content-Disposition: attachment; filename="<dir>.zip"`, `X-ZDDC-Source: subtree-zip`. ACL-filtered per file's containing-dir `.zddc` chain (per-dir decision cache, same as `serveArchiveListing`); skips `.`/`_`-prefixed entries (`.zddc`, `_template`, `_app`); adds a `.zip` *file* it meets as opaque bytes (does not recurse). Streamed, so an empty/fully-denied subtree is a valid empty zip, not a 403. The query check is in `dispatch`'s `info.IsDir()` branch right after the directory ACL gate (so it works on `/dir` and `/dir/`); code: `handler.ServeSubtreeZip`. The browse tool's toolbar "Download (zip)" button uses it in server mode; offline it bundles the picked folder with JSZip (`confirm()` above ~2000 files / ~500 MB).
|
|
|
|
### Client mode (proxy / cache / mirror)
|
|
|
|
When `--upstream <url>` is set, the binary runs as a **downstream client** of another zddc-server instead of a master. `cmd/zddc-server/main.go` short-circuits to `runClient(cfg)`, which builds a `*cache.Cache` from `zddc/internal/cache/` and uses it as the entire request handler — no archive index, no apps server, no watcher, no OPA decider, no ACL middleware, no token store.
|
|
|
|
Three modes via `--mode <proxy|cache|mirror>` (default `cache`). Cache directory layout is intentionally a normal ZDDC root: `<master>/foo/bar.txt` → `<root>/foo/bar.txt`. Unset `--upstream` and the same root serves as a plain master, useful for portable offline snapshots.
|
|
|
|
Pipeline:
|
|
- Cache hit → serve immediately + background `If-Modified-Since` revalidate (304 no-op, 200 overwrite, 403/404 purge).
|
|
- Cache miss → forward to upstream; stream response simultaneously to client and a tmp-file atomically renamed into the cache.
|
|
- Network error + cached version → serve stale + `X-ZDDC-Cache: offline`.
|
|
- Network error + no cache → 503 + `X-ZDDC-Cache: offline`.
|
|
- Directory listings cached as `<dir>/.zddc-listing.<html|json>` sidecars (Accept-varied).
|
|
- `Cache-Control: no-store` / `private` responses pass through but are not persisted.
|
|
- **Writes** (PUT / POST / DELETE) forward to upstream when online; on transport error, queue in `<root>/.zddc-outbox/<id>/` (meta + body) and return `202 Accepted` + `X-ZDDC-Cache: queued`. Background loop replays in order — 2xx deletes the entry, 412 → `<id>.conflict-<ts>/`, 4xx-other drops, 5xx defers. PUT/DELETE include `If-Unmodified-Since` from the cached mtime so the master can reject conflicting writes.
|
|
- **Mirror mode** (`--mode mirror`): adds an access-triggered subtree walker (rate-limited via `--mirror-min-interval`, default 1h) that recursively pre-fetches under `--mirror-subtree`s; idle mirrors generate zero upstream traffic.
|
|
|
|
Two-instance smoke test recipe:
|
|
|
|
```sh
|
|
# Master.
|
|
mkdir -p /tmp/m && echo 'admins: [you@example.com]' > /tmp/m/.zddc
|
|
echo "hello" > /tmp/m/hello.txt
|
|
zddc-server --root /tmp/m --addr 127.0.0.1:18443 --tls-cert=none --no-auth &
|
|
|
|
# Client (cache mode).
|
|
mkdir -p /tmp/c
|
|
zddc-server --root /tmp/c --addr 127.0.0.1:18444 --tls-cert=none \
|
|
--upstream http://127.0.0.1:18443 --mode cache --no-auth &
|
|
|
|
curl -sI http://127.0.0.1:18444/hello.txt | grep -i x-zddc-cache # → miss
|
|
curl -sI http://127.0.0.1:18444/hello.txt | grep -i x-zddc-cache # → hit
|
|
ls /tmp/c # → hello.txt + .zddc-upstream marker
|
|
kill %1; sleep 1
|
|
curl -sI http://127.0.0.1:18444/hello.txt | grep -i x-zddc-cache # → hit (still served from disk)
|
|
curl -si http://127.0.0.1:18444/never.txt | head -1 # → 503
|
|
```
|
|
|
|
`X-ZDDC-Cache` response header values: `miss`, `hit`, `proxy` (no-persist or directory), `offline` (network unreachable). Useful for browser-side freshness UI.
|
|
|
|
Implementation: `zddc/internal/cache/cache.go` (a single file). Tests in `zddc/internal/cache/cache_test.go` use `httptest.NewServer` as a fake upstream and cover hit/miss/offline/range/bearer-forwarding/no-store paths.
|
|
|
|
### Bearer tokens (CLI auth)
|
|
|
|
zddc-server self-issues bearer tokens for CLI / non-browser callers. No external IDP, no JWKS rotation. Source of truth: `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` — a YAML file per token with `email`, `created`, `expires`, `description`. Filename is the **hash** of the token; the plaintext is never persisted.
|
|
|
|
User flow: sign in to the master in a browser, visit `/.tokens`, click "Create token," copy the value (shown once). Store in a 0600 file and pass `--bearer-file <path>` to a CLI that calls back into zddc-server, or send `Authorization: Bearer <token>` directly from scripts.
|
|
|
|
Endpoints:
|
|
- `GET /.tokens` — HTML self-service page (gated by browser auth).
|
|
- `GET/POST /.api/tokens` — list / create. Plaintext returned **only** on POST response.
|
|
- `DELETE /.api/tokens/<id>` — revoke. `<id>` is the 8-char short ID or full hash.
|
|
|
|
Validation flow inside the request path: `ACLMiddleware` checks for `Authorization: Bearer …` first; on success, sets the request email from the token file. On any failure (unknown / expired / store unavailable), returns 401 — there is no fallback to header-based auth, so a misconfigured client can't silently masquerade as anonymous. If no Bearer is present, the existing `cfg.EmailHeader` path runs unchanged.
|
|
|
|
The tokens directory inherits the existing `.zddc.d/` exclusion: dot-prefix segments 404 from direct GETs, and `fs.ListDirectory` filters them from listings. **Verify on any new deployment by attempting `GET /.zddc.d/tokens/anything` and confirming 404.**
|
|
|
|
Implementation: `zddc/internal/auth/` (storage), `zddc/internal/handler/tokenhandler.go` (HTTP layer), middleware extension in `zddc/internal/handler/middleware.go`.
|
|
|
|
### Admin elevation (sudo-style)
|
|
|
|
Admins are treated as normal users by default; admin escape hatches (WORM bypass, auto-own takeover, `.zddc` edit authority, profile admin scaffolds) require an explicit per-request opt-in. The toggle lives in every tool's header (left of the theme button) and writes a `zddc-elevate=1` cookie (Max-Age=1800, SameSite=Lax) — 30-minute sudo window before it auto-expires.
|
|
|
|
Server-side the model is `zddc.Principal{Email, Elevated}`. `ACLMiddleware` builds it once per request and stashes it in context; `IsAdmin` / `IsSubtreeAdmin` / `CanEditZddc` take a `Principal` parameter rather than a bare email. That signature change is the enforcement mechanism — the compiler tells you when an admin call site doesn't thread elevation, so a "forgot to gate this" mistake doesn't compile. `PrincipalFromContext(r)` is the one-call-per-site bundling helper.
|
|
|
|
Bearer tokens are **implicitly elevated** — CLI clients and the mirror process can't toggle a cookie, and their authority is the bearer's full grant by design. Browser sessions elevate only when the user opts in.
|
|
|
|
`/.profile/access` exposes `can_elevate` (elevation-independent "does this email have any admin grant anywhere?") so the header toggle can render itself for an un-elevated admin who hasn't opted in yet. The access log captures `elevated=<true|false>` per request for forensics.
|
|
|
|
Implementation: `zddc/internal/zddc/admin.go` (Principal struct + gated functions), `zddc/internal/handler/middleware.go` (cookie/bearer → ElevatedKey context value), `shared/elevation.{js,css}` (header toggle UI, concat'd into every tool's bundle).
|
|
|
|
### Release tagging
|
|
|
|
zddc-server has no separate release script. The top-level `./build release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with their per-platform canonical symlinks (`zddc-server_<platform>` → `zddc-server_v<X.Y.Z>_<platform>`), regenerates the per-version + canonical stub pages, refreshes the index, and tags `zddc-server-v<X.Y.Z>` alongside the seven HTML-tool tags.
|
|
|
|
```sh
|
|
./build release # lockstep stable, coordinated next version
|
|
./build release 1.2.0 # lockstep stable, explicit version
|
|
./build beta # internal SHA snapshot for the BMC dev chart
|
|
./deploy --releases # publish the bundle to /srv/zddc/releases/
|
|
```
|
|
|
|
The script tags every tool but does NOT push — finish with `git push origin main && git push origin --tags` (and run `./deploy` to put the artifacts on the live site).
|
|
|
|
**Versioning** — clean semver. Stable cuts emit one `<tool>-vX.Y.Z` tag per tool, all 8 sharing the same X.Y.Z. No `-alpha.N` / `-beta.N` counter tags — the canonical URL `<tool>.html` is the stable URL; counters would defeat that. Historical per-tool independent tags (`archive-v0.0.2`, `zddc-server-v0.0.7`, etc.) stay as artifacts; the next coordinated cut jumps every tool to the same number.
|
|
|
|
**Binary distribution** — `/srv/zddc/releases/zddc-server_<X>_<platform>` (on the deploy host) are real static files served from `zddc.varasys.io/releases/`. No Codeberg release assets, no `$CODEBERG_TOKEN`, no third-party mirror, no LFS. The matrix-cell link points at `zddc-server_<X>.html`, a generated stub page that surfaces the four platform downloads in one click.
|
|
|
|
There is no CI for this — solo workflow benefits from one canonical
|
|
local path that fails loudly and visibly on the developer's terminal.
|
|
|
|
### Notes
|
|
|
|
- No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/`. The Go toolchain is not on the host; use the `localhost/zddc-go:1.24` image as documented in the **Test** subsection above.
|
|
- Portfolio files (`*.portfolio`) in the served tree appear as virtual group directories
|
|
- Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two entries per tracking number: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both **serve in place** — the handler streams the first chronologically received copy's bytes back at the `.archive/` URL without redirecting. The per-transmittal URL is intentionally hidden so external links of the form `.archive/<tracking>.html#section` keep tracking the latest revision (a redirect would expose the snapshot URL and people would forward THAT instead). Cache-Control is `no-cache` so each load revalidates against the on-disk file's Last-Modified/ETag; when a new revision lands the resolver picks it and the browser refetches. Modifier files (`<tracking>_<rev>+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree.
|
|
- ACL is enforced via cascading `.zddc` YAML files — first-explicit-match-wins evaluated bottom-up (deepest level first), with deny checked before allow within a single `.zddc`; default-deny when any `.zddc` exists in the chain. Authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`). Operator-facing detail, anti-patterns, worked layouts, the verify-it-works recipe, and the federal-readiness gap analysis are in `zddc/README.md` § "Access control: the `.zddc` cascade." The architectural framing (cooperating layers, commercial-vs-federal trust model, why archive auto-serves at every directory) is in `ARCHITECTURE.md` § "Server security model."
|
|
- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page".
|
|
- `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by the dev-shell pod's Caddy to gate `/devshell/*` (code-server) on root-admin status without code-server learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint.
|
|
- **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like the `_template/` directory created by the self-contained install snippet, still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable.
|
|
- **Caching on embedded tool HTMLs** (landing, browse served at `/`, plus the five canonical app HTMLs at `<dir>/<app>.html`): `Cache-Control: public, max-age=0, must-revalidate` + content-addressed `ETag` (sha256 hex prefix). Browser revalidates on every load; matching ETag returns `304 Not Modified` with empty body. ETag changes only when the binary is redeployed (computed once at startup from `EmbeddedBytes` + `BuildVer`, memoized).
|
|
- **Compression**: gzip middleware (`github.com/klauspost/compress/gzhttp`) wraps the entire mux. Skipped for bodies under 1 KB and for 304 responses. Roughly 75% size reduction on tool HTMLs and JSON listings.
|
|
- **Public landing page**: `GET /` (HTML or JSON) bypasses the directory-level ACL gate so anonymous callers see the project picker. Per-project filtering inside `fs.ListDirectory` still hides projects the caller can't reach. Subdirectory ACL gates remain in force.
|
|
- **Audit log**: every request is mirrored to a JSON-line file under `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` (configurable via `--access-log` / `ZDDC_ACCESS_LOG`, opt out with empty). Lumberjack rotation (100 MB / 10 backups / 90 days, gzip). Hostname is in both the filename and every record's `host` field — multi-replica deployments sharing one `.zddc.d/` dir disambiguate cleanly.
|
|
- **HTTP timeouts**: `ReadHeaderTimeout: 10s, ReadTimeout: 60s, WriteTimeout: 60s, IdleTimeout: 120s`. Slowloris-resistant; legit traffic completes in milliseconds even with gzip.
|