Compare commits

..

No commits in common. "49866f63538c9b0742638ffc5831dc71daa0cfd5" and "cef7188a7763c225520e9123ba9c7b6e84adc9dc" have entirely different histories.

50 changed files with 2470 additions and 9933 deletions

233
AGENTS.md
View file

@ -6,17 +6,16 @@
# ── ./build subcommands ──────────────────────────────────────────────────── # ── ./build subcommands ────────────────────────────────────────────────────
# `./build` (no arg) is a source-side dev build only — assembles tool/dist/ # `./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 # + 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 # left alone. Channel + release subcommands produce a complete release
# chart (no public artifacts). `./build release` is the canonical stable # bundle in dist/release-output/ (gitignored). Run `./deploy` to publish.
# cut. Run `./deploy` to publish a stable cut. # Workflow: alpha = active dev → beta = ready for testing → release = ship.
./build # dev build (no release bundle) ./build # dev build (no release bundle)
./build beta # internal SHA snapshot for BMC dev chart ./build alpha # cut alpha (cascades nothing)
# (regenerates embedded/* + chore commit; ./build beta # cut beta (cascades alpha → beta)
# no public artifacts in dist/release-output/) ./build release # cut stable, coordinated next version
./build release # coordinated stable cut, next version # (cascades alpha + beta → new stable; tags all nine)
# (tags all 8 artifacts at release commit) ./build release 1.2.0 # cut stable at explicit version
./build release 1.2.0 # coordinated stable cut, explicit version
./build help ./build help
# ── ./deploy subcommands ──────────────────────────────────────────────────── # ── ./deploy subcommands ────────────────────────────────────────────────────
@ -30,9 +29,10 @@
# Single-tool dev build for testing (does NOT touch dist/release-output/): # Single-tool dev build for testing (does NOT touch dist/release-output/):
sh tool/build.sh # archive|transmittal|classifier|landing|form|tables|browse sh tool/build.sh # archive|transmittal|classifier|landing|form|tables|browse
# Single-tool stable cut (rare; prefer ./build release so versions don't # Single-tool release (rare; prefer ./build alpha|beta|release so versions
# drift between tools). # don't drift between tools). Same flag form as before.
sh tool/build.sh --release [<version>] sh tool/build.sh --release [<version>|alpha|beta]
./freshen-channel <tool> <channel> # rebuild one tool's alpha/beta from its current stable tag
# Test all tools # Test all tools
npm test npm test
@ -47,14 +47,14 @@ npx playwright test tool # archive | transmittal | classifier | brow
No lint, typecheck, or format commands exist — the project is plain sh + vanilla JS. No lint, typecheck, or format commands exist — the project is plain sh + vanilla JS.
Stable cuts seed `dist/release-output/` from the current Channel/release cuts seed `dist/release-output/` from the current
`/srv/zddc/releases/` — copying only immutable per-version files `/srv/zddc/releases/` (preserving symlinks) before running per-tool
(`<tool>_v<X.Y.Z>.html`, `zddc-server_v<X.Y.Z>_<plat>`) + their `.sig` promote, then mutate the channels being cut on top. The bundle is
sidecars + `pubkey.pem`. The cut writes this version's per-version therefore always a complete intended-live snapshot, not a sparse diff.
file + canonical `<tool>.html` / `zddc-server_<plat>` symlinks on top. The build ends with a **channel-link verifier** that asserts every
`./deploy --releases` (rsync `--delete-after`) cleanses any stale `<tool>_{stable,beta,alpha}.html` (and zddc-server's per-platform binary
files in the live tree that this cut didn't include. mirrors + stub pages) resolves. Build fails if any link is dangling —
because the bundle is complete, dangling-link errors mean a real bug.
**Nothing is pushed automatically.** Run `./deploy` to publish; commit **Nothing is pushed automatically.** Run `./deploy` to publish; commit
+ push source changes to `main` separately. + push source changes to `main` separately.
@ -104,9 +104,10 @@ shared/
# index.html regenerated by `./build` # index.html regenerated by `./build`
# <tool>_v<X.Y.Z>.html per-version (immutable) # <tool>_v<X.Y.Z>.html per-version (immutable)
# <tool>_v<X.Y>.html -> ... symlink chain # <tool>_v<X.Y>.html -> ... symlink chain
# <tool>.html -> ... canonical symlink → current stable # <tool>_stable.html -> ... channel mirror, follows latest stable
# <tool>_{beta,alpha}.html -> ... channels (cascade to stable when idle)
# zddc-server_v<X.Y.Z>_<platform> per-platform binary (raw bytes, no LFS) # 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_<channel>_<platform> channel binary mirror (symlink)
# zddc-server_<X>.html stub page surfacing 4 platform DLs # zddc-server_<X>.html stub page surfacing 4 platform DLs
helm/ helm/
@ -117,7 +118,7 @@ helm/
**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. **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. **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 alpha|beta|release` into `dist/release-output/` and rsync'd to `/srv/zddc/releases/` by `./deploy --releases`. Per-version files (HTML and zddc-server binaries) are real immutable bytes; partial-version pins (`_v<X.Y>`, `_v<X>`) and channel mirrors (`_stable`, `_beta`, `_alpha`) are symlinks. `shared/build-lib.sh` provides `promote_release` (HTML tools) and `promote_zddc_server` (binaries + matching stub pages); the top-level `./build` seeds 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`) ## Shared CSS (`shared/base.css`)
@ -143,7 +144,7 @@ Included as the **first** positional arg to every tool's `concat_files` CSS call
- Build scripts use **POSIX sh** (`#!/bin/sh` with `set -eu`), not bash. - Build scripts use **POSIX sh** (`#!/bin/sh` with `set -eu`), not bash.
- `concat_files` accepts **positional args only** (not array names). - `concat_files` accepts **positional args only** (not array names).
- `awk` processes `template.html`, replacing `{{PLACEHOLDER}}` markers and stripping CDN `<script>`/`<link>` tags (pattern: `https?://`) - `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). - `{{BUILD_LABEL}}` is substituted in all eight HTML tools via `gsub` in awk (use `gsub`, not `print` — the placeholder is inline in an HTML line). Value is `Built: <timestamp> BETA` for dev builds, `v<version>` for stable releases, and `<channel> · <date> · <sha>` for alpha/beta channel builds; computed before the awk step. The shared `is_red` flag controls whether the label is wrapped in a red+bold `<span>` (true for dev/alpha/beta, false for stable).
- Cleans up temp files via `trap cleanup EXIT` - 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: **`</` escaping is mandatory.** Any JS containing `</tag>` inside string or template literals will break inline `<script>` embedding. Run:
@ -227,60 +228,77 @@ Format: `trackingNumber_revision (status) - title.extension`
- 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 - 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 - Release artifacts live on the deploy host (`/srv/zddc/`), not in any git history. Use `./deploy` to publish
### Releasing — lockstep stable + beta snapshot ### Releasing — lockstep, channels, layout
**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`. **Lockstep convention.** Every release cut bumps all nine artifacts (8 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 nine tools) + 1` — `_coordinated_next_stable` in `shared/build-lib.sh`. Channel cuts (alpha/beta) follow the same lockstep — every tool's channel mirror is overwritten in step. Three channels, ordered: **alpha** (dev iteration) → **beta** (general testing) → **stable** (ship).
**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 alpha|beta|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.
**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 | | 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>_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>.html`, `<tool>_v<X>.html` | symlinks | partial-version pins |
| `<tool>_v<X.Y.Z>.html.sig` | real, immutable | Ed25519 detached signature | | `<tool>_<channel>.html` | symlink (or real bytes during active channel dev) | mutable channel mirror per tool, channel ∈ {stable, beta, alpha} |
| `<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_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>_<platform>`, `zddc-server_v<X>_<platform>`, `zddc-server_<channel>_<platform>` | symlinks (or real bytes during active channel dev) | partial-pin and channel mirrors per platform — same cascade as the HTML tools |
| `zddc-server_v<X.Y.Z>_<platform>.sig` | real | matching detached signature | | `zddc-server_<X>.html` | generated stub page | per-version / per-channel; lists the four platform downloads. This is what the matrix-cell link points at — one stub fans out to four binaries |
| `zddc-server_<platform>.sig` | symlink | canonical .sig URL | | `index.html` | regenerated by `build.sh` | matrix table, one column per tool, one row per release |
| `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. **Single point of truth.** `./build release` is the canonical lockstep cut. It seeds `dist/release-output/` from `/srv/zddc/releases/` (so cascades and the verifier see a complete world), forwards each HTML tool's build with the agreed version, then `promote_zddc_server` (in `shared/build-lib.sh`) copies the freshly cross-compiled binaries into `dist/release-output/` with the matching symlink chain, then `write_zddc_server_stubs_all` regenerates every stub page, then `build_releases_index` rewrites the index, then `verify_channel_links` asserts nothing dangles. **Then** the top-level build folds the regenerated `zddc/internal/apps/embedded/*` files into a `release: vX.Y.Z lockstep` commit and tags all nine artifacts at that commit. `./deploy --releases` then 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. - **Stable** (`./build release` or `--release X.Y.Z`): Writes per-version HTML for the eight HTML tools + per-version binaries for zddc-server (real bytes, immutable). Refreshes 5 symlinks per HTML tool + 5 symlinks per zddc-server platform → the new version. Updates `zddc/internal/apps/embedded/*` to stable-labeled bytes, makes a release commit, tags all nine (`<tool>-v<X.Y.Z>`) **at that commit** so binaries built from the tag embed clean stable bytes. Cascade: stable cut means beta and alpha both reset to stable for every tool.
- **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. - **Beta** (`./build beta`): Overwrites `<tool>_beta.html` with dist bytes for each HTML tool, and `zddc-server_beta_<platform>` with each platform's binary. Updates `zddc/internal/apps/embedded/*` to beta-labeled bytes (the dev image picks them up via `ZDDC_REF=main`). Cascade: `<tool>_alpha.html``<tool>_beta.html` and `zddc-server_alpha_<platform>``zddc-server_beta_<platform>` (symlinks). No tag.
- **Alpha** (`./build alpha`): Overwrites only the alpha mirrors in `dist/release-output/`, all nine artifacts. **Does NOT update `zddc/internal/apps/embedded/`** — the project invariant is that alpha is never baked into the binary. No tag, no other side-effects.
- **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. - **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/`: **Bake-in invariant** — what zddc-server's binary embeds via `//go:embed` from `zddc/internal/apps/embedded/`:
| Image | Chart pin | Embeds | | Image | `ZDDC_REF` | Embeds |
|---|---|---| |---|---|---|
| Prod (Dockerfile.prod, BMCD) | `appVersion: "X.Y.Z"` → tag `zddc-server-v<X.Y.Z>` | Stable-labeled bytes from the tagged release commit | | Prod (Dockerfile.prod, BMCD) | `stable` (latest tag) | 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) | | Dev (Dockerfile, devshell) | `main` | Beta or stable bytes — whatever the last beta/stable cut wrote |
| Local dev iteration | n/a | Use `tool/dist/<tool>.html` directly; binary's embedded copy lags | | Local dev iteration | n/a | Use `tool/dist/<tool>.html` directly; binary's embedded copy lags |
**Alpha is never baked in.** Active dev work uses the tool's local dist HTML opened directly in a browser; the binary's embedded copy is the "default fallback" served when no `.zddc apps:` override exists, and only ever holds beta or stable bytes.
On-page `{{BUILD_LABEL}}` format (HTML tools only — zddc-server's version comes from the binary itself): 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. - Plain dev: `vX.Y.Z-alpha · <full-ts> · <sha>[-dirty]` (red), where X.Y.Z is the per-tool next-stable target.
- `./build beta`: `vX.Y.Z-beta · <full-ts> · <sha>` (red). Only seen on the dev chart's compiled binary. - `--release alpha`: `vX.Y.Z-alpha · <date> · <sha>` (red).
- `./build release [X.Y.Z]`: `v<X.Y.Z>` (black). - `--release beta`: `vX.Y.Z-beta · <date> · <sha>` (red).
- `--release [version]`: `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. After cutting a stable release, `git push origin main && git push origin --tags` to publish the new version files + symlinks + every per-tool tag in lockstep.
### Release discipline (MUST rules) ### Channel discipline (MUST rules)
The build enforces lockstep mechanically (one command bumps all 8). The rules below are still on you. The build enforces lockstep mechanically (one command bumps all nine). 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. 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. Stable 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. 2. **Lockstep is the contract.** Don't cut a single tool's release without bumping the rest. The HTML tool's standalone `--release` 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. 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. 4. **Alpha and beta are mutable.** Document this anywhere you invite users to test them. Pinning a deployment to a channel mirror means it gets rebuilt without notice. For reproducibility, pin to a per-version URL — `<tool>_v0.0.5.html` or `zddc-server_v0.0.5.html`.
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`. 5. **Cascade is automatic.** Stable cut → beta + alpha mirrors reset to stable (per-tool HTML AND per-platform zddc-server). Beta cut → alpha → beta. "No active beta" silently shows current stable. No freshen step required after a stable release.
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. 6. **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`.
7. **Beta soak before promoting (recommended).** Give a beta a few days of exposure before cutting the same code as stable. Not enforced; use judgment for trivial changes.
### Freshen helper
`./freshen-channel <tool> <channel>` rebuilds the alpha or beta channel of a tool from its current stable tag — useful when you want a channel to advance to current stable code without doing active dev on it (e.g. after upstream dependency changes). Most of the time you don't need it: the cascade rule (rule 5 above) means a stable cut already resets the downstream channel symlinks. Use this when you specifically want a fresh build with a new on-page label timestamp instead of a symlink.
```sh
./freshen-channel archive alpha
./freshen-channel transmittal beta
```
What it does:
1. Finds the latest `<tool>-v*` clean stable tag.
2. Creates a temporary git worktree at that tag — does **not** touch the main worktree's HEAD or working tree.
3. Runs `<tool>/build.sh --release <channel>` inside the worktree, which overwrites `<tool>_<channel>.html` with the freshly-built bytes. (Note: this is in the worktree, not on main — you'll need to commit the resulting changes back to main afterward.)
4. Removes the worktree.
The build pipeline used is the one **at the tag**, not on `main`. That is intentional (pure reproducibility). If you have made build-system improvements since stable was cut and want the freshen to use them, cut a new stable first.
### Install model ### Install model
@ -291,7 +309,7 @@ No install script. Two paths:
To override at any level, either: To override at any level, either:
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority). 1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
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.) 2. Write an `apps:` entry in any `.zddc` along the path. Spec is one of `stable`/`beta`/`alpha`/`v0.0.4`/`v0.0`/`v0`/full URL/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. 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.
@ -364,15 +382,11 @@ A schema-driven form renderer used to collect structured data into YAML files in
- `GET /<dir>/<id>.yaml.html` — render form pre-filled from `<id>.yaml` - `GET /<dir>/<id>.yaml.html` — render form pre-filled from `<id>.yaml`
- `POST /<dir>/<id>.yaml.html` — overwrite that submission → 200 - `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): **Storage**: spec at `<dir>/form.yaml`, submissions at `<dir>/<YYYY-MM-DD>-<email-sanitized>.yaml` (siblings of the spec). Copying `<dir>` elsewhere copies the spec plus every submission together. ACL applies via the existing `.zddc` cascade.
- **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. **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. **Validator subset** (`zddc/internal/jsonschema/`): `type` (string/number/integer/boolean/array/object), `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `required`, `additionalProperties: false`, `properties`, `items`, `format` (`date`, `email`). 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. **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.
@ -401,56 +415,14 @@ Read/aggregate counterpart to the form system. Renders a directory of YAML row f
- **Nested sub-tables**`<dir>/sub-list/table.yaml` is its own self-contained table at `<dir>/sub-list/table.html`. Composition, not violation. - **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. - **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). - **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. - **Future per-row history** — `<dir>/.history/<id>/<timestamp>.yaml` if/when version sidecars are added.
**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 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`. **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 (no enums or regex constraints) so projects pick their own conventions. Operators customize by dropping their own `table.yaml` + `form.yaml` into `archive/<party>/mdl/`; both files override the embedded defaults atomically (no merge — operator-supplied wins entirely). 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`. **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 ## 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: 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:
@ -464,46 +436,6 @@ This is a guideline, not a rule. Revisit per-feature: when v1+ form-spec adds `$
Go HTTP server sub-project living at `zddc/`. Replaces `caddy file-server --browse` for ZDDC archives. 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 on `working/`/`staging/`/`reviewing/`). 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 ### 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). 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).
@ -520,7 +452,7 @@ podman run --rm --network=host -v "$PWD":/src:Z -v /tmp/gocache:/root/go/pkg/mod
# launch the binary on the host (`./zddc/zddc-server`). # 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/`. 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 alpha|beta|release` it also promotes those binaries to `dist/release-output/` with the matching symlink chain and stub pages — same lockstep flow as the HTML tools. `./deploy` rsyncs the bundle to `/srv/zddc/releases/`.
### Test ### Test
@ -685,18 +617,19 @@ Implementation: `zddc/internal/zddc/admin.go` (Principal struct + gated function
### Release tagging ### 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. zddc-server has no separate release script. The top-level `./build alpha|beta|release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the lockstep symlink chain (one set of symlinks per platform), regenerates the per-version + per-channel stub pages, refreshes the index, and (on stable) tags `zddc-server-v<X.Y.Z>` alongside the eight HTML-tool tags.
```sh ```sh
./build release # lockstep stable, coordinated next version ./build release # lockstep stable, coordinated next version
./build release 1.2.0 # lockstep stable, explicit version ./build release 1.2.0 # lockstep stable, explicit version
./build beta # internal SHA snapshot for the BMC dev chart ./build alpha # lockstep alpha cut for everything
./build beta # lockstep beta cut for everything
./deploy --releases # publish the bundle to /srv/zddc/releases/ ./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). 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. **Versioning** — clean semver. Stable cuts emit one `<tool>-vX.Y.Z` tag per tool, all nine sharing the same X.Y.Z. No `-alpha.N` / `-beta.N` counter tags — channel URLs are stable URLs by design. 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. **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.

View file

@ -43,19 +43,19 @@ Website files (what `zddc.varasys.io` serves) live on a **separate Codeberg repo
README.md, LICENSE # repo housekeeping README.md, LICENSE # repo housekeeping
# NO releases/ — release artifacts are NOT in any git history. # NO releases/ — release artifacts are NOT in any git history.
~/src/zddc/dist/release-output/ (gitignored, produced by ./build release) ~/src/zddc/dist/release-output/ (gitignored, produced by ./build alpha|beta|release)
index.html # download page, regenerated by build index.html # download page, regenerated by build
<tool>_v<X.Y.Z>.html # real per-version HTML (immutable) <tool>_v<X.Y.Z>.html # real per-version HTML (immutable)
<tool>_v<X.Y.Z>.html.sig # detached Ed25519 signature <tool>_v<X.Y>.html → ... # symlink: latest patch within X.Y.*
<tool>.html → <tool>_v<X.Y.Z>.html # canonical "current stable" symlink <tool>_v<X>.html → ... # symlink: latest within X.*.*
<tool>.html.sig → <tool>_v<X.Y.Z>.html.sig # canonical .sig symlink (chains to per-version .sig) <tool>_stable.html → ... # symlink: current stable HTML
<tool>_beta.html → ... # symlink to stable (or real bytes when active beta dev)
<tool>_alpha.html → ... # symlink to beta/stable (or real bytes when active alpha dev)
zddc-server_v<X.Y.Z>_<platform> # real per-version cross-compiled binary (raw bytes, no LFS) zddc-server_v<X.Y.Z>_<platform> # real per-version cross-compiled binary (raw bytes, no LFS)
zddc-server_v<X.Y.Z>_<platform>.sig # detached signature zddc-server_v<X.Y>_<platform> → ... # symlink chain (mirrors the HTML cascade per platform)
zddc-server_<platform> → ... # canonical per-platform symlink → current stable zddc-server_v<X>_<platform> → ...
zddc-server_<platform>.sig → ... # canonical .sig symlink zddc-server_<channel>_<platform> → ... # channel mirror per platform
zddc-server_v<X.Y.Z>.html # per-version stub: 4 platform downloads for that version zddc-server_<X>.html # generated stub: cell link → fans out 4 platform downloads
zddc-server.html # canonical stub: 4 platform downloads for current stable
pubkey.pem # signing pubkey (seeded from live)
/srv/zddc/ (deploy host; Caddy bind-mount) /srv/zddc/ (deploy host; Caddy bind-mount)
index.html, reference.html, css/, js/, img/ ← rsync'd from ~/src/zddc-website/ index.html, reference.html, css/, js/, img/ ← rsync'd from ~/src/zddc-website/
@ -64,11 +64,11 @@ Website files (what `zddc.varasys.io` serves) live on a **separate Codeberg repo
`<tool>` ∈ {archive, transmittal, classifier, landing, form, tables, browse}. `<platform>` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}. `<tool>` ∈ {archive, transmittal, classifier, landing, form, tables, browse}. `<platform>` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}.
Every URL under `/releases/` resolves directly via the symlink chain — no `manifest.json`, no Caddy regex-rewrite, no JavaScript indirection, no third-party mirror. Caddy serves these as plain static files. Two URL shapes per tool: `<tool>.html` (canonical, mutable symlink → current stable) and `<tool>_v<X.Y.Z>.html` (immutable per-version pin). Same for zddc-server per platform. The May 2026 simplification dropped channel mirrors (`_stable`, `_beta`, `_alpha`) and partial-version pins (`_v<X.Y>`, `_v<X>`) — operators pin to exact versions when they want stability, otherwise track the canonical URL. Every URL under `/releases/` resolves directly via the symlink chain — no `manifest.json`, no Caddy regex-rewrite, no JavaScript indirection, no third-party mirror. Caddy serves these as plain static files. The Docker-tag pattern: `:1.2.3` is pinned, `:1.2` floats, `:1` floats further, `:stable` floats furthest, and `:beta` / `:alpha` are mutable channel mirrors that overwrite in place.
**zddc-server binaries are reproducible from a tag, not in git** — `./build release` cross-compiles them into `dist/release-output/`, `./deploy` rsyncs them to `/srv/zddc/releases/`, Caddy serves from there. Older versions: `git checkout zddc-server-v0.0.8 && ./build release 0.0.8`. The `helm/zddc-server-{prod,dev,cache}/` charts build from source via init container, but operators who want a prebuilt binary just `curl -O https://zddc.varasys.io/releases/zddc-server_linux-amd64`. The four-platform fan-out lives at `zddc-server.html` (current stable) or `zddc-server_v<X.Y.Z>.html` (per-version). **zddc-server binaries are reproducible from a tag, not in git** — `./build alpha|beta|release` cross-compiles them into `dist/release-output/`, `./deploy` rsyncs them to `/srv/zddc/releases/`, Caddy serves from there. Older versions: `git checkout zddc-server-v0.0.8 && ./build release 0.0.8`. The `helm/zddc-server-{prod,dev,cache}/` charts build from source via init container, but operators who want a prebuilt binary just `curl -O https://zddc.varasys.io/releases/zddc-server_stable_linux-amd64`. The single cell link per release points at `zddc-server_<X>.html`, a small generated stub that surfaces all four platform downloads.
To preview a build locally, open `dist/tool.html` directly via the dev server. To publish on `zddc.varasys.io`, cut a release with `./build release` and then `./deploy`. To preview a build locally, open `dist/tool.html` directly via the dev server. To publish on `zddc.varasys.io`, cut a release with `./build alpha|beta|release` and then `./deploy`.
Vendor dependencies (bundled third-party libraries) live in `tool/vendor/` if present. The build script is responsible for inlining them into the output. Vendor dependencies (bundled third-party libraries) live in `tool/vendor/` if present. The build script is responsible for inlining them into the output.
@ -105,36 +105,42 @@ Each HTML tool's `build.sh`:
2. Reads JS files in declaration order, concatenates them 2. Reads JS files in declaration order, concatenates them
3. Processes `template.html` with `awk`, replacing `{{PLACEHOLDER}}` markers with the concatenated content and stripping CDN `<script>`/`<link>` tags 3. Processes `template.html` with `awk`, replacing `{{PLACEHOLDER}}` markers with the concatenated content and stripping CDN `<script>`/`<link>` tags
4. Writes the result to `dist/tool.html` 4. Writes the result to `dist/tool.html`
5. If `--release [<version>]` was passed (stable cut), calls `promote_release` to write into `dist/release-output/`: per-version immutable file + canonical `<tool>.html` symlink + .sig companion. Beta cuts skip — `./build beta` is internal-only (embedded regen + chore commit), no public artifact. 5. If `--release <channel-or-version>` was passed, calls `promote_release` to write into `dist/release-output/` (per-version file + symlink updates for stable; channel mirror overwrite for alpha/beta).
The top-level `./build` at the repository root is the canonical lockstep entry point. It: The top-level `./build` at the repository root is the canonical lockstep entry point. It:
1. On a stable cut, **seeds `dist/release-output/` from `/srv/zddc/releases/`** — copying only the immutable per-version files (`<tool>_v<X.Y.Z>.html`, `zddc-server_v<X.Y.Z>_<plat>`) + their `.sig` sidecars + `pubkey.pem`. The canonical symlinks get rewritten by this cut; any stale files in the live tree are cleaned by deploy's `--delete-after`. 1. On a channel/release cut, **seeds `dist/release-output/` from `/srv/zddc/releases/`** (preserving symlinks) so the bundle is a complete intended-live snapshot, not a sparse one-channel diff. Cascades and the verifier downstream see the same world the live site has.
2. Forwards `--release [version]` to every HTML tool's build (or `--release beta` for the snapshot path), computing a coordinated next-stable target via `_coordinated_next_stable` (max of every tool's latest tag + 1) when no explicit version is given. 2. Forwards `--release [version|alpha|beta]` to every HTML tool's build, computing a coordinated next-stable target via `_coordinated_next_stable` (max of every tool's latest tag + 1) when no explicit version is given.
3. Cross-compiles zddc-server for the four target platforms inside a containerized Go toolchain (podman/docker). 3. Cross-compiles zddc-server for the four target platforms inside a containerized Go toolchain (podman/docker).
4. On a stable cut, calls `promote_zddc_server` to copy the freshly cross-compiled binaries into `dist/release-output/` with per-platform canonical symlinks (`zddc-server_<platform>`) and `.sig` companions; tagging `zddc-server-v<X.Y.Z>` is deferred to the embedded-commit block at the end. 4. On a channel/release cut, calls `promote_zddc_server` to copy the freshly cross-compiled binaries into `dist/release-output/` with the matching symlink chain (one set per platform) and tag `zddc-server-v<X.Y.Z>` alongside the eight HTML-tool tags (stable cuts only).
5. Calls `write_zddc_server_stubs_all` to refresh the per-version + canonical stub HTML pages from whatever artifacts are in `dist/release-output/`. 5. Calls `write_zddc_server_stubs_all` to refresh the per-version + per-channel stub HTML pages from whatever artifacts are in `dist/release-output/`.
6. Regenerates `dist/release-output/index.html` as the action-first download page. 6. Regenerates `dist/release-output/index.html` as the action-first download page.
7. Calls `verify_channel_links` — fails the build if any channel link is dangling.
Then `./deploy --releases` rsyncs `dist/release-output/``/srv/zddc/releases/` with `--delete-after`. Then `./deploy --releases` rsyncs `dist/release-output/``/srv/zddc/releases/` with `--delete-after`.
### Release verbs ### Channels
Two release verbs (plus dev). The May 2026 simplification dropped alpha and made beta internal-only. Three release channels, applied in lockstep across all nine artifacts (8 HTML + zddc-server). The cascade rule keeps downstream channel symlinks current automatically.
- **Stable** — versioned, immutable. `./build release [version]` writes per-version HTML for the seven HTML tools and per-version binaries for zddc-server (real bytes), writes the canonical `<tool>.html` and `zddc-server_<platform>` symlinks → the new version, signs every per-version artifact (and companion `.sig` symlink for the canonical URL), and tags `<tool>-v<X.Y.Z>` for every tool at the release commit. - **Stable** — versioned, immutable. `./build release [version]` writes per-version HTML for the eight HTML tools and per-version binaries for zddc-server (real bytes), refreshes the symlink chain (5 symlinks per HTML tool + 5 symlinks per zddc-server platform) all → the new version, and tags `<tool>-v<X.Y.Z>` for every tool. Skips per-tool HTML rewrites when source hasn't changed since that tool's last stable tag (binaries always rebuild).
- **Beta**`./build beta` is the BMC dev chart's plumbing. It regenerates `zddc/internal/apps/embedded/*` with beta-labeled HTML bytes and makes a `chore(embedded): cut v<X.Y.Z>-beta` commit. No public artifact in `dist/release-output/`. The chart's appVersion pins to `"<X.Y.Z>-beta-<sha>"`; its Dockerfile parses the suffix and `git fetch`-es that SHA, compiling its own binary from the fetched source. - **Beta**`./build beta` overwrites `<tool>_beta.html` for each HTML tool and `zddc-server_beta_<platform>` for each platform with fresh bytes. Cascades alpha → beta for both HTML and binaries (one symlink per platform). No tag — channel URLs are stable URLs by design.
- **Dev** — plain `./build` (no arg) produces `dist/<tool>.html` and `zddc/dist/zddc-server-<platform>` binaries; doesn't touch `dist/release-output/`, the live site, or `embedded/`. - **Alpha** — `./build alpha` overwrites only the alpha mirrors, all nine artifacts. No tag, no other side-effects.
The on-page `{{BUILD_LABEL}}` is rendered red+bold for dev/beta builds (`is_red=1`) and black for stable releases. The label format is: A plain `./build` (no arg) is a dev build: it produces `dist/<tool>.html` and `zddc/dist/zddc-server-<platform>` binaries; doesn't touch `dist/release-output/` or the live site. The download index, stub pages, and verifier only run when a channel/release is being cut.
The cascade rule (stable cut → beta + alpha mirrors reset to stable; beta cut → alpha resets to beta) means downstream channels are never stale across either HTML or binaries. "No active beta" silently shows current stable; "no active alpha" silently shows current beta or stable. Operators don't need to run a freshen step after each stable release.
The on-page `{{BUILD_LABEL}}` is rendered red+bold for dev/alpha/beta builds (`is_red=1`) and black for stable releases. The label format is:
| Build | Label | | Build | Label |
|--------------------|--------------------------------------------------------| |--------------------|--------------------------------------------------------|
| dev (no `--release`) | `v0.0.6-dev · 2026-04-27 14:00:00 · abc1234[-dirty]` | | dev (no `--release`) | `v0.0.6-alpha · 2026-04-27 14:00:00 · abc1234[-dirty]` |
| `--release beta` | `v0.0.6-beta · 2026-04-27 14:00:00 · abc1234` | | `--release alpha` | `v0.0.6-alpha · 2026-04-27 · abc1234` |
| `--release beta` | `v0.0.6-beta · 2026-04-27 · abc1234` |
| `--release [ver]` | `v0.0.5` | | `--release [ver]` | `v0.0.5` |
`X.Y.Z` for non-stable labels is the **next-stable target** — patch+1 from the latest clean `<tool>-vX.Y.Z` tag. Dev builds use the `-dirty` marker so iterative work is distinguishable from a formal beta cut. `X.Y.Z` for non-stable labels is the **next-stable target** — patch+1 from the latest clean `<tool>-vX.Y.Z` tag. Dev builds use the full timestamp + `-dirty` marker so iterative work is distinguishable from a formal `--release alpha` cut (which stamps date-only and is committed-clean by definition).
### Install distribution model ### Install distribution model
@ -155,7 +161,7 @@ Two orthogonal axes: how the bytes get there (this section), and what runtime mo
Resolution order at a request to `<dir>/<app>.html` where the app is available: Resolution order at a request to `<dir>/<app>.html` where the app is available:
1. **Override** — real `.html` file at the path → static handler. 1. **Override** — real `.html` file at the path → static handler.
2. **`.zddc apps:` cascade** — walk leaf→root for an `apps.<app>` entry. Spec is `stable` (canonical "current stable"), `v0.0.4` (exact-version pin), full URL (custom mirror), or local path. Closer-to-leaf wins. 2. **`.zddc apps:` cascade** — walk leaf→root for an `apps.<app>` entry. Spec is `stable`/`beta`/`alpha` (canonical channel), `v0.0.4`/`v0.0`/`v0` (canonical version), full URL (custom mirror), or local path. Closer-to-leaf wins.
3. **Embedded** — the build-time HTML compiled into the binary. 3. **Embedded** — the build-time HTML compiled into the binary.
URL sources fetch once on first request and cache forever in `<ZDDC_ROOT>/_app/<host>/<path>`. There is no background refresh, no SHA-256 verification, no admin UI. To pull a new build, delete the cache file. Concurrent misses for the same URL share one outbound fetch (hand-rolled singleflight). Failed fetches fall through to embedded with a one-time WARN log per source URL. Direct URL access to `/_app/...` is blocked at dispatch. URL sources fetch once on first request and cache forever in `<ZDDC_ROOT>/_app/<host>/<path>`. There is no background refresh, no SHA-256 verification, no admin UI. To pull a new build, delete the cache file. Concurrent misses for the same URL share one outbound fetch (hand-rolled singleflight). Failed fetches fall through to embedded with a one-time WARN log per source URL. Direct URL access to `/_app/...` is blocked at dispatch.
@ -171,10 +177,10 @@ Independent of how the tool got installed. `archive` auto-detects from the URL a
Every `build.sh` must: Every `build.sh` must:
- Begin with `#!/bin/sh` and `set -eu` (POSIX sh, not bash) - Begin with `#!/bin/sh` and `set -eu` (POSIX sh, not bash)
- Source `shared/build-lib.sh` first (provides `ensure_exists`, `concat_files`, `build_timestamp`, `compute_build_label`, `promote_release`, plus the lockstep helpers `_coordinated_next_stable`, `promote_zddc_server`, `write_zddc_server_stubs_all`) - Source `shared/build-lib.sh` first (provides `ensure_exists`, `concat_files`, `build_timestamp`, `compute_build_label`, `promote_release`, plus the lockstep helpers `_coordinated_next_stable`, `promote_zddc_server`, `write_zddc_server_stubs_all`, `verify_channel_links`)
- Fail immediately on missing source files (`ensure_exists` pattern) - Fail immediately on missing source files (`ensure_exists` pattern)
- Clean up temp files on exit (use `trap cleanup EXIT`) - Clean up temp files on exit (use `trap cleanup EXIT`)
- Accept `--release [<version>]` for stable cuts or `--release beta` for snapshot cuts; otherwise produce a dev build - Accept `--release [<version>|alpha|beta]` — explicit version or channel name; otherwise produce a dev build
### HTML Embedding Safety ### HTML Embedding Safety
@ -468,9 +474,7 @@ app.state.subscribe((property, newValue) => {
- `js/post.js` — POST + handle 200/201/422/403/409 responses - `js/post.js` — POST + handle 200/201/422/403/409 responses
- `js/main.js` — boot: load context, mount root widget, wire submit - `js/main.js` — boot: load context, mount root widget, wire submit
**Server-side counterpart:** `zddc/internal/handler/formhandler.go` recognizes `*.form.html` and `*.yaml.html` URLs, parses the spec, validates submissions via `zddc/internal/jsonschema/`, writes via `zddc.WriteAtomic` (plain submissions) or `zddc/internal/handler/history.go` `WriteWithHistory` (record-typed YAML — mdl rows, rsk rows, ssr.yaml). Existence of `<name>.form.yaml` is the trigger; without it, the URL falls through to static-file serving. **Server-side counterpart:** `zddc/internal/handler/formhandler.go` recognizes `*.form.html` and `*.yaml.html` URLs, parses the spec, validates submissions via `zddc/internal/jsonschema/`, writes via `zddc.WriteAtomic`. Existence of `<name>.form.yaml` is the trigger; without it, the URL falls through to static-file serving.
**Record-vs-submission distinction.** "Records" are the three table-store types (mdl/rsk/ssr); everything else is a "submission." Records get server-stamped audit fields (`created_at`/`_by`, `updated_at`/`_by`, `revision`, `previous_sha`), an immutable per-record history at `<dir>/.history/<base>/<RFC3339Nano>-<sha8>.<ext>`, cascade-driven filename composition (via the `records:` + `field_codes:` `.zddc` keys), and per-folder field locking (e.g. type=RSK in rsk/). The mechanism intercepts at the file-API write path (`serveFilePut`): if `isRecordPath` matches, the call routes through `WriteWithHistory`; otherwise the historical `WriteAtomic` path is used. See AGENTS.md "Records, audit, and history" for the operator surface; `zddc/internal/handler/history.go` for the orchestration.
**Round-trip philosophy:** v0 is "form-as-truth" — submission YAML is regenerated from form state on every save. Hand-edits to submission files are not preserved across re-edit→re-submit. v1 will add an opt-in "file-as-truth" mode (eemeli/yaml Document API) for forms like `.zddc` itself where users hand-edit and comments must survive. **Round-trip philosophy:** v0 is "form-as-truth" — submission YAML is regenerated from form state on every save. Hand-edits to submission files are not preserved across re-edit→re-submit. v1 will add an opt-in "file-as-truth" mode (eemeli/yaml Document API) for forms like `.zddc` itself where users hand-edit and comments must survive.

View file

@ -35,21 +35,15 @@ This is a **monorepo of independent tools**, not one application:
# zddc-server. Does NOT touch dist/release-output/ or the live site. # zddc-server. Does NOT touch dist/release-output/ or the live site.
./build ./build
# ./build beta — internal SHA snapshot for the BMC dev chart pipeline. # Channel/release cuts — produce a complete release bundle in
# Regenerates zddc/internal/apps/embedded/* and makes a # dist/release-output/ (gitignored). Cuts seed from the live site
# `chore(embedded): cut v<X.Y.Z>-beta` commit. NO public artifacts. # (/srv/zddc/releases/) so the bundle is a complete intended-live
# The chart's appVersion pins to "<X.Y.Z>-beta-<sha>"; its Dockerfile # snapshot, not a sparse diff. Run ./deploy to publish.
# parses the suffix and fetches that SHA from git. ./build alpha # cut alpha (cascades nothing)
./build beta ./build beta # cut beta (cascades alpha → beta)
# ./build release # cut stable, coordinated next version
# ./build release — coordinated stable cut. Regenerates embedded/, # (cascades alpha + beta → new stable; tags all nine artifacts)
# makes a release commit, tags all 8 artifacts, writes per-tool ./build release X.Y.Z # cut stable at explicit version
# <tool>_v<X.Y.Z>.html + <tool>.html canonical symlink, and zddc-server
# per-platform binaries + canonical symlinks into dist/release-output/.
# Bundle seeded from /srv/zddc/releases/ so prior immutable per-version
# artifacts survive.
./build release # coordinated next-stable version
./build release X.Y.Z # explicit stable version
./build help # usage ./build help # usage
# Deploy — atomic-ish rsync of the build output + content repo to # Deploy — atomic-ish rsync of the build output + content repo to
@ -59,7 +53,8 @@ This is a **monorepo of independent tools**, not one application:
./deploy --releases # only dist/release-output/ → /srv/zddc/releases/ ./deploy --releases # only dist/release-output/ → /srv/zddc/releases/
sh tool/build.sh # iterate on one HTML tool's dist/ sh tool/build.sh # iterate on one HTML tool's dist/
sh tool/build.sh --release [X.Y.Z] # single-tool stable cut (rare; prefer ./build release) sh tool/build.sh --release [...] # single-tool release (rare; prefer the lockstep ./build)
./freshen-channel <tool> <channel> # rebuild one tool's alpha/beta from its current stable tag
npm test # all Playwright specs (build first!) npm test # all Playwright specs (build first!)
npx playwright test <tool> # one spec npx playwright test <tool> # one spec
@ -73,17 +68,16 @@ No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSI
## Things that bite if you forget ## Things that bite if you forget
- **`dist/` is gitignored.** `tool/dist/<tool>.html` is the canonical built artifact for testing and the source for `./build release` writes. `dist/release-output/` is the local-only release bundle. Never hand-edit a `dist/` file. - **`dist/` is gitignored.** `tool/dist/<tool>.html` is the canonical built artifact for testing and as the source for `--release` writes. `dist/release-output/` is the local-only release bundle written by `./build alpha|beta|release`. Never hand-edit a `dist/` file.
- **Build vs deploy are separate verbs.** `./build` and `./build release` produce artifacts under `dist/release-output/`. Nothing escapes the source tree until the operator runs `./deploy`, which rsyncs into `/srv/zddc/` (Caddy's bind-mount). This decouples local iteration from live state. - **Build vs deploy are separate verbs.** `./build` and `./build alpha|beta|release` produce artifacts under `dist/release-output/`. Nothing escapes the source tree until the operator runs `./deploy`, which rsyncs into `/srv/zddc/` (Caddy's bind-mount). This decouples local iteration from live state.
- **Stable cuts seed from live state.** Before running per-tool promote, `./build release` clears `dist/release-output/` and copies only the per-version immutable files (`<tool>_v<X.Y.Z>.html`, `zddc-server_v<X.Y.Z>_<plat>`) plus their `.sig` sidecars from `/srv/zddc/releases/`. The cut writes this version's per-version files + refreshes the 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. - **Channel/release cuts seed from live state.** Before running per-tool promote, `./build alpha|beta|release` clears `dist/release-output/` and copies `/srv/zddc/releases/` into it (preserving symlinks). The cut then mutates the channels being cut on top. Result: `dist/release-output/` is always a complete intended-live snapshot, the verifier sees a complete world, and `./deploy --releases` (rsync `--delete-after`) replaces live state cleanly.
- **Lockstep releases.** Every release cut bumps all 8 artifacts (7 HTML tools + zddc-server) to the same version, even if a tool didn't change. The coordinated next-stable target is `max(latest tag across all tools) + 1`. Per-tool independent versions are gone — `./build release` is the canonical path. Stable cuts atomically (1) regenerate `zddc/internal/apps/embedded/` with stable-labeled bytes, (2) make a `release: vX.Y.Z lockstep` commit, (3) tag all 8 artifacts at that commit. Tags always point at a clean release commit. (Anchor fix May 2026; see git log around the v0.0.9 re-anchor.) - **Lockstep releases.** Every release cut bumps all nine artifacts (8 HTML tools + zddc-server) to the same version, even if a tool didn't change. The coordinated next-stable target is `max(latest tag across all tools) + 1`. Per-tool independent versions are no longer the norm — `./build release` is the canonical path. Workflow: alpha = active dev, beta = ready for general testing, stable = ready to ship. Stable cuts atomically (1) regenerate `zddc/internal/apps/embedded/` with stable-labeled bytes, (2) make a `release: vX.Y.Z lockstep` commit, (3) tag all nine artifacts at that commit. Tags ALWAYS point at a clean release commit — never at a source-side commit with alpha-dirty embedded files. (Fixed in May 2026; see git log around the v0.0.9 re-anchor.)
- **Bake-in invariant.** What zddc-server's binary embeds via `//go:embed`: prod images (chart's Dockerfile.prod fetches the latest stable tag) ship that cut's bytes. Dev images (chart's Dockerfile fetches `appVersion`, which is either a stable tag or a `<X.Y.Z>-beta-<sha>` snapshot SHA) ship the bytes that ref carries. Plain `./build` (no arg) leaves `embedded/` untouched — local dev iteration uses `tool/dist/<tool>.html` opened directly, not the baked binary copy. - **Bake-in invariant.** What zddc-server's binary embeds via `//go:embed`: prod images (built from `ZDDC_REF=stable`) ship the latest stable cut's bytes. Dev images (built from `ZDDC_REF=main`) ship whatever the last beta-or-stable cut wrote — no alpha. **Alpha is never baked in.** Active dev iteration uses `tool/dist/<tool>.html` opened directly, not the binary's embedded copy. The `./build` (no arg) and `./build alpha` paths intentionally leave `embedded/` untouched.
- **Release artifact layout** (in `dist/release-output/`, mirrored to `/srv/zddc/releases/`): - **Release artifact layout** (in `dist/release-output/`, mirrored to `/srv/zddc/releases/`). HTML tools: per-version `<tool>_v<X.Y.Z>.html` (real immutable files) + partial-version pins (`<tool>_v<X.Y>.html`, `_v<X>.html`) + channel mirrors (`<tool>_{stable,beta,alpha}.html`) — all symlinks except per-version. zddc-server: `zddc-server_v<X.Y.Z>_<platform>` per-version binaries (raw bytes, no LFS), `_v<X.Y>_<platform>` / `_v<X>_<platform>` / `_<channel>_<platform>` symlinks, plus `zddc-server_<X>.html` stub pages that surface the four platform downloads in one matrix-cell link. Same cascade rule for both: stable cut → beta + alpha both reset to stable; beta cut → alpha cascades to beta.
- HTML tools: `<tool>_v<X.Y.Z>.html` (real immutable file) + `<tool>.html` (symlink → current stable's per-version file). Each carries a sibling `.sig` (real for per-version, symlink for canonical). - **No tags for alpha/beta.** Channel URLs are stable URLs by design — appending counter tags would defeat the purpose. The on-page label encodes `<date> · <sha>` for traceability. Stable cuts get clean `<tool>-vX.Y.Z` tags for every tool (nine tags per cut, all sharing the same X.Y.Z).
- zddc-server: `zddc-server_v<X.Y.Z>_<platform>` (real immutable binary, no LFS) + `zddc-server_<platform>` (symlink → current stable's per-version binary). Same `.sig` pairing. Plus a single `zddc-server.html` stub page that surfaces the four-platform downloads of the current stable. - **Pre-release semver in the on-page label.** Plain dev builds and `--release alpha|beta` cuts embed `vX.Y.Z-{alpha,beta}` in `{{BUILD_LABEL}}` where X.Y.Z is the next-stable target. Plain dev adds a full timestamp + `-dirty` marker; `--release alpha|beta` is date-only.
- No channel mirrors (`_alpha`, `_beta`, `_stable`), no partial-version pins (`_v<X.Y>`, `_v<X>`). Dropped in the May 2026 simplification. - **Channel-link verifier.** Every `./build alpha|beta|release` ends with a check that every `<tool>_{stable,beta,alpha}.html` (and zddc-server's per-platform binary mirrors + stub pages) resolves. Because cuts seed from live state, the verifier always sees a complete world; missing-link errors mean a real problem, not a sparse-bundle artifact.
- **On-page build label.** Plain dev builds: `v<X.Y.Z>-dev · <full-ts> · <sha>[-dirty]` (red), where X.Y.Z is the next-stable target. `./build beta`: `v<X.Y.Z>-beta · <full-ts> · <sha>` (red) — only seen on the dev chart's compiled binary. Stable cuts: clean `v<X.Y.Z>`. - **`./build` (no arg) is a source-side dev build.** Assembles `tool/dist/` + cross-compiled binaries; does NOT touch `dist/release-output/` or the live site. Use it to iterate without affecting anything. To produce a deployable bundle, run `./build alpha|beta|release`. To publish, run `./deploy`. Nothing is pushed to Codeberg automatically.
- **`./build` (no arg) is a source-side dev build.** Assembles `tool/dist/` + cross-compiled binaries; does NOT touch `dist/release-output/`, embedded files, or the live site. Use it to iterate without affecting anything. `./build beta` adds the embedded regen + chore commit (BMC dev chart consumes the SHA via appVersion). `./build release` produces the deployable bundle. `./deploy` publishes. Nothing is pushed to Codeberg automatically.
- **Always build before running tests** — Playwright opens `dist/tool.html` via `file://`. - **Always build before running tests** — Playwright opens `dist/tool.html` via `file://`.
- **`</` in JS string/template literals breaks inline `<script>`** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining. - **`</` in JS string/template literals breaks inline `<script>`** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining.
- **All ZDDC parsing/formatting/hashing goes through `window.zddc`** (from `shared/zddc.js` + `shared/hash.js` + `shared/zddc-filter.js`). API: `parseFilename`, `parseFolder`, `parseRevision`, `formatFilename`, `formatFolder`, `compareRevisions`, `isValidStatus`, `splitExtension`, `joinExtension`, `crypto.{sha256Hex, sha256String, sha256File, bytesToHex}`, `filter.{parse, matches}`. File objects across tools use `trackingNumber` (string) and `extension` (string, **no leading dot** — use `zddc.joinExtension(name, ext)` to build a filename). Add edge cases to `tests/zddc.spec.js`, not per-tool tests. - **All ZDDC parsing/formatting/hashing goes through `window.zddc`** (from `shared/zddc.js` + `shared/hash.js` + `shared/zddc-filter.js`). API: `parseFilename`, `parseFolder`, `parseRevision`, `formatFilename`, `formatFolder`, `compareRevisions`, `isValidStatus`, `splitExtension`, `joinExtension`, `crypto.{sha256Hex, sha256String, sha256File, bytesToHex}`, `filter.{parse, matches}`. File objects across tools use `trackingNumber` (string) and `extension` (string, **no leading dot** — use `zddc.joinExtension(name, ext)` to build a filename). Add edge cases to `tests/zddc.spec.js`, not per-tool tests.

View file

@ -22,47 +22,6 @@ The name "Zero Day Document Control" comes from the convention itself — adopt
Each tool is published in three channels (stable, beta, alpha) as static files served from <https://zddc.varasys.io/releases/>. **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/`, browse under `working/`+`reviewing/` (browse hosts the in-place markdown editor), classifier under `incoming/`, tables at `archive/<party>/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 `<ZDDC_ROOT>/_app/` — or drop a real `.html` file at any path. Each tool is published in three channels (stable, beta, alpha) as static files served from <https://zddc.varasys.io/releases/>. **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/`, browse under `working/`+`reviewing/` (browse hosts the in-place markdown editor), classifier under `incoming/`, tables at `archive/<party>/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 `<ZDDC_ROOT>/_app/` — or drop a real `.html` file at any path.
## Deploy: bootstrap config
> **A fresh `zddc-server` deployment grants no access to anyone until two config files are populated.** Without them, the server runs but every request returns 403. The embedded `defaults.zddc.yaml` ships with empty role members so deployments must opt-in to authorize anyone.
**Step 1.** At the master root, create `/.zddc` (i.e. `<ZDDC_ROOT>/.zddc`) naming at least one admin:
```yaml
admins:
- cwitt@burnsmcd.com
```
`admins:` is honored only at the root file. Admins behave as normal users by default and elevate per-request via the `zddc-elevate=1` cookie (header toggle in every tool) or implicitly when authenticating with a bearer token.
**Step 2.** In each project, create `<project>/.zddc` to populate the `document_controller` and `project_team` role members:
```yaml
title: "Project Phoenix"
roles:
document_controller:
members:
- dc1@burnsmcd.com
project_team:
members:
- alice@burnsmcd.com
- '*@acme.com' # external counterparty (glob)
```
That's it. The embedded cascade does the rest — `project_team` gets read across the project; `document_controller` gets write/create authority on the archive subtree, WORM filing rights on `received/issued`, and subtree-admin of `working/`/`staging/`/`reviewing/`.
**Common footgun.** `acl: { allow: [...] }` is silently ignored (the YAML parses, but `ACLRules` only reads `permissions:`). The correct shape is:
```yaml
acl:
permissions:
'<principal>': <bits>
```
Bits are any subset of `r w c d a` (read / write / create / delete / admin); empty string is an explicit deny. Principals are emails, globs like `*@domain.com`, or role names (anything without an `@`).
`zddc-server` prints a startup warning when the root `.zddc` grants nobody anything — watch for it on first boot. For the full schema, run `zddc-server show-defaults` (dumps the embedded `defaults.zddc.yaml` with annotated comments).
## File-naming convention ## File-naming convention
The full specification — filename format, tracking numbers, revision rules, status codes, folder naming, and the transmittal workflow — lives at <https://zddc.varasys.io/reference.html>. The full specification — filename format, tracking numbers, revision rules, status codes, folder naming, and the transmittal workflow — lives at <https://zddc.varasys.io/reference.html>.

View file

@ -116,7 +116,4 @@ echo "Wrote $output_html"
# Promote AFTER the dist file exists so promote_release can copy from # Promote AFTER the dist file exists so promote_release can copy from
# $output_html. (The order matters — _promote_stable does cp $output_html ...) # $output_html. (The order matters — _promote_stable does cp $output_html ...)
# Only fires on a release cut; plain dev builds leave release-output alone.
if [ "$is_release" = "1" ]; then
promote_release "$tool" promote_release "$tool"
fi

353
build
View file

@ -6,68 +6,68 @@ set -eu
# ./build dev build: assemble tool dist/, cross-compile # ./build dev build: assemble tool dist/, cross-compile
# zddc-server binaries. Nothing else is touched # zddc-server binaries. Nothing else is touched
# — no release artifacts produced, no deploy, # — no release artifacts produced, no deploy,
# zddc/internal/apps/embedded/ is left alone # and zddc/internal/apps/embedded/ is left alone
# (binary will embed whatever the last beta or # (binary will embed whatever the last beta or
# stable cut committed there). # stable cut committed there).
# ./build beta internal SHA snapshot for the BMC dev chart. # ./build alpha cut alpha: produce a complete release bundle
# Updates embedded/ with current tool HTMLs + # in dist/release-output/ (cascades nothing).
# makes a `chore(embedded): cut v<X.Y.Z>-beta` # Like dev, embedded/ is NOT updated — the
# commit; the chart's appVersion pins to that # invariant is that alpha labels are never baked
# SHA via Dockerfile parsing. NO public # into the binary.
# artifact in dist/release-output/. # ./build beta cut beta (cascades alpha → beta). Updates
# ./build release cut coordinated stable. Updates embedded/ # embedded/ with beta-labeled tool HTMLs and
# with stable-labeled bytes, makes a release # commits them — the dev image (which builds
# commit, tags all 8 artifacts at that commit, # from main) ships those bytes.
# writes <tool>_v<X.Y.Z>.html + <tool>.html # ./build release cut coordinated stable (cascades alpha + beta
# symlink for every tool and the zddc-server # → new stable; updates embedded/ with stable
# per-platform binaries into # labels, makes a release commit, tags all
# dist/release-output/. # seven tools at that commit). Prod images
# (which build from the latest stable tag)
# ship those bytes.
# ./build release X.Y.Z same, explicit version. # ./build release X.Y.Z same, explicit version.
# ./build help this message. # ./build help this message.
# #
# Lockstep: every release cut bumps all 8 tools (7 HTML + zddc-server) # Lockstep: every channel/release cut bumps all seven tools (6 HTML +
# together. Coordinated next-stable = max(latest tag) + 1. # zddc-server) together. Coordinated next-stable = max(latest tag) + 1.
# #
# Stable release cuts write a complete intended-live snapshot to # Channel/release cuts write a complete intended-live snapshot to
# ${ZDDC_DEPLOY_RELEASES_DIR:-$SCRIPT_DIR/dist/release-output}. The build # ${ZDDC_DEPLOY_RELEASES_DIR:-$SCRIPT_DIR/dist/release-output}. The build
# does NOT touch the live site — run `./deploy --releases` to rsync the # does NOT touch the live site — run `./deploy` (or `./deploy --releases`)
# snapshot into /srv/zddc/. The snapshot is seeded from the current # to rsync the snapshot into /srv/zddc/. The snapshot is built by seeding
# live state's per-version files (so older immutable artifacts are # from the current live state (so cascades and the verifier see a
# preserved), then this cut's <tool>.html canonical symlinks + new # complete world), then mutating the channel(s) being cut on top.
# per-version file are written on top.
# #
# Bake-in invariant (what zddc-server's binary embeds via //go:embed): # Bake-in invariant (what zddc-server's binary embeds via //go:embed):
# - prod image (Dockerfile.prod): always stable bytes — chart's # - prod image (Dockerfile.prod, ZDDC_REF=stable): always stable bytes
# Dockerfile.prod fetches the source at the latest # - dev image (Dockerfile, ZDDC_REF=main): stable OR beta bytes
# zddc-server-vX.Y.Z tag. # (whatever last beta/
# - dev image (Dockerfile): stable OR beta-snapshot bytes — the # stable cut wrote)
# chart's appVersion is set to either "X.Y.Z" (stable) # - alpha is NEVER baked in. Active dev iteration happens via the tool's
# or "X.Y.Z-beta-<sha>" (snapshot), and Dockerfile # local dist/<tool>.html, not via the binary's embedded copy.
# fetches that ref. Dev builds (`./build` no-arg) do
# NOT touch embedded/, so the binary's baked copy stays
# at whatever the last beta or stable cut wrote.
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
# Source build-lib.sh once at the top level so the helpers it provides # Source build-lib.sh once at the top level so the helpers it provides
# (promote_zddc_server, write_zddc_server_stubs_all, # (promote_zddc_server, write_zddc_server_stubs_all, verify_channel_links,
# _coordinated_next_stable) are in scope. Each tool's build.sh sources # _coordinated_next_stable) are in scope. Each tool's build.sh sources it
# it again — that's a no-op on already-defined functions. # again — that's a no-op on already-defined functions.
root_dir="$SCRIPT_DIR" root_dir="$SCRIPT_DIR"
. "$SCRIPT_DIR/shared/build-lib.sh" . "$SCRIPT_DIR/shared/build-lib.sh"
# --- Parse subcommand ------------------------------------------------------ # --- Parse subcommand ------------------------------------------------------
# RELEASE_CHANNEL empty means dev mode (build only, no release output); # RELEASE_CHANNEL empty means dev mode (build only, no website worktree
# "beta" means an internal SHA snapshot (regenerate embedded/ + commit, # writes); set means a channel/release cut that promotes to the website
# no public artifact); "stable" means a coordinated release cut that # worktree under $ZDDC_DEPLOY_RELEASES_DIR.
# writes to dist/release-output/.
RELEASE_CHANNEL="" RELEASE_CHANNEL=""
RELEASE_VERSION="" RELEASE_VERSION=""
case "${1:-dev}" in case "${1:-dev}" in
dev|build) dev|build)
# Dev build: tool dist/ + zddc-server binaries only. Touches # Dev build: tool dist/ + zddc-server binaries only. Touches
# nothing in release-output. # nothing in the website worktree.
;;
alpha)
RELEASE_CHANNEL="alpha"
;; ;;
beta) beta)
RELEASE_CHANNEL="beta" RELEASE_CHANNEL="beta"
@ -84,7 +84,7 @@ case "${1:-dev}" in
fi fi
;; ;;
help | -h | --help) help | -h | --help)
sed -n '4,30p' "$0" | sed 's/^# \{0,1\}//' sed -n '4,22p' "$0" | sed 's/^# \{0,1\}//'
exit 0 exit 0
;; ;;
*) *)
@ -112,41 +112,32 @@ export ZDDC_DEPLOY_RELEASES_DIR="${ZDDC_DEPLOY_RELEASES_DIR:-$SCRIPT_DIR/dist/re
RELEASES_DIR="$ZDDC_DEPLOY_RELEASES_DIR" RELEASES_DIR="$ZDDC_DEPLOY_RELEASES_DIR"
mkdir -p "$RELEASES_DIR" mkdir -p "$RELEASES_DIR"
# On a stable cut, seed RELEASES_DIR from the current live site so the # When cutting a channel/release, seed RELEASES_DIR from the current live
# resulting bundle is a complete intended-live snapshot, not a sparse # site so the resulting bundle is a complete intended-live snapshot, not
# diff. The seed copies the immutable per-version files # a sparse one-channel diff. Two reasons:
# (<tool>_v<X.Y.Z>.html, zddc-server_v<X.Y.Z>_<plat>) plus their .sig # 1. Per-tool promote_release does cascade writes (beta cut → also
# files. The cut then writes this version's new per-version files + # rewrites alpha to track beta; stable cut → resets alpha + beta).
# refreshes the canonical <tool>.html / zddc-server_<plat> symlinks on # The cascade itself is deterministic, but downstream artifacts that
# top. `./deploy --releases` (rsync --delete-after) wipes any stale # were NOT touched by this cut (e.g. older versioned files, the
# files in /srv/zddc/releases/ that aren't in the bundle. # other channel mirrors, partial-version symlinks) still need to be
# # present in the bundle so `./deploy --releases` (rsync
# We skip the seed for beta cuts (no public artifacts to produce). # --delete-after) doesn't wipe them off the live site.
# Bootstrap case (no live site yet, or empty live releases dir) is # 2. verify_channel_links cross-checks the full release tree; it
# flags absent channels as missing. With seeding, a fresh
# `dist/release-output/` matches live state, the cut mutates on
# top, and the verifier sees a complete world.
# Bootstrap case (no live site yet, or live releases dir empty) is
# silently skipped — the very first stable cut populates everything. # silently skipped — the very first stable cut populates everything.
if [ "$RELEASE_CHANNEL" = "stable" ]; then if [ -n "$RELEASE_CHANNEL" ]; then
LIVE_RELEASES="${ZDDC_LIVE_DIR:-/srv/zddc}/releases" LIVE_RELEASES="${ZDDC_LIVE_DIR:-/srv/zddc}/releases"
if [ -d "$LIVE_RELEASES" ] && [ -n "$(ls -A "$LIVE_RELEASES" 2>/dev/null)" ]; then if [ -d "$LIVE_RELEASES" ] && [ -n "$(ls -A "$LIVE_RELEASES" 2>/dev/null)" ]; then
echo "=== Seeding $RELEASES_DIR from $LIVE_RELEASES (per-version artifacts only) ===" echo "=== Seeding $RELEASES_DIR from $LIVE_RELEASES ==="
rm -rf "$RELEASES_DIR" rm -rf "$RELEASES_DIR"
mkdir -p "$RELEASES_DIR" mkdir -p "$RELEASES_DIR"
# Copy per-version immutable files + their .sig sidecars only. # cp -a preserves the symlink graph (channel mirrors +
# Strict X.Y.Z match avoids picking up legacy partial-version # _v<X.Y> / _v<X> partial-version pins) so cascade decisions
# pins (_v<X.Y>, _v<X>) that may still be lying around as # downstream see the same world the live site has.
# leftover .sig files in /srv/zddc/releases/ from the pre- cp -a "$LIVE_RELEASES/." "$RELEASES_DIR/"
# simplification layout. The canonical <tool>.html /
# zddc-server_<plat> symlinks will be rewritten by this cut;
# anything else (channel mirrors, partial pins, retired tools)
# gets cleaned by deploy's --delete-after rsync.
find "$LIVE_RELEASES" -maxdepth 1 -type f -regextype posix-extended \( \
-regex '.*/[a-z-]+_v[0-9]+\.[0-9]+\.[0-9]+\.html' -o \
-regex '.*/[a-z-]+_v[0-9]+\.[0-9]+\.[0-9]+\.html\.sig' -o \
-regex '.*/zddc-server_v[0-9]+\.[0-9]+\.[0-9]+_.*' \
\) -exec cp -a '{}' "$RELEASES_DIR/" \;
# Also seed the public key (it lives at the releases root).
if [ -f "$LIVE_RELEASES/pubkey.pem" ]; then
cp -a "$LIVE_RELEASES/pubkey.pem" "$RELEASES_DIR/"
fi
fi fi
fi fi
@ -192,10 +183,11 @@ echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,form,tables,brow
# Mirror the cascade-served HTMLs into the apps embed source dir so the # Mirror the cascade-served HTMLs into the apps embed source dir so the
# next `go build` of zddc-server picks them up via //go:embed. ONLY happens # next `go build` of zddc-server picks them up via //go:embed. ONLY happens
# on a beta or stable cut — beta cuts feed the dev image (chart pins by # on a beta or stable cut — that's the project invariant: alpha labels are
# SHA to the embedded-commit), stable cuts feed the prod image (chart # never baked into the binary, beta labels go to the dev image (which builds
# pins to the tag). Plain `./build` leaves embedded files untouched — # from main), and stable labels go to prod (which builds from the latest
# whatever the last beta or stable cut committed stays in place. # stable tag). Plain `./build` and `./build alpha` leave the embedded files
# untouched; whatever the last beta/stable cut committed remains in place.
EMBED_DIR="$SCRIPT_DIR/zddc/internal/apps/embedded" EMBED_DIR="$SCRIPT_DIR/zddc/internal/apps/embedded"
if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
mkdir -p "$EMBED_DIR" mkdir -p "$EMBED_DIR"
@ -312,12 +304,10 @@ echo " binary version: $ZDDC_BINARY_VERSION"
' '
# --- Sign release artifacts ----------------------------------------------- # --- Sign release artifacts -----------------------------------------------
# After a stable cut has populated $RELEASES_DIR with the actual bytes # After a channel/release cut has populated $RELEASES_DIR with the actual
# for this build, walk the dir and produce a detached Ed25519 .sig # bytes for this build, walk the dir and produce a detached Ed25519 .sig
# alongside every immutable per-version artifact. Canonical symlinks # alongside every real artifact. Symlinks (channel mirrors, partial-version
# (<tool>.html, zddc-server_<plat>) skip — the .sig at the symlink's # pins) skip — the .sig at the symlink's target is what counts.
# target is what counts, and a companion .sig symlink (also written
# by promote_release) chains the canonical .sig URL to that target.
# #
# Operators verify with stdlib openssl: # Operators verify with stdlib openssl:
# #
@ -351,15 +341,14 @@ sign_release_artifacts() {
fi fi
# Collect the artifact list first so the signing loop runs in this # Collect the artifact list first so the signing loop runs in this
# shell (no subshell counter scope issue). find: real files only # shell (no subshell counter scope issue). find: real files only
# (-P, the default), matching <tool>_v*.html and # (-P, the default), matching <tool>_v*.html, <tool>_<channel>.html,
# zddc-server_v*_<plat>(.exe). The canonical symlinks (<tool>.html / # and zddc-server_v*_<plat>(.exe). Excludes the index, stub pages,
# zddc-server_<plat>) don't get separate .sig files — verification # and any pre-existing .sig files.
# follows the symlink to the immutable per-version file whose .sig
# is signed below. Excludes the index, stub pages, and pre-existing
# .sig files.
_list=$(find "$_dir" -maxdepth 1 -type f \( \ _list=$(find "$_dir" -maxdepth 1 -type f \( \
-name '*_v*.html' -o \ -name '*_v*.html' -o \
-name 'zddc-server_v*' \ -name '*_stable.html' -o -name '*_beta.html' -o -name '*_alpha.html' -o \
-name 'zddc-server_v*' -o \
-name 'zddc-server_stable_*' -o -name 'zddc-server_beta_*' -o -name 'zddc-server_alpha_*' \
\) ! -name '*.sig' ! -name 'index.html' ! -name 'zddc-server_*.html' 2>/dev/null) \) ! -name '*.sig' ! -name 'index.html' ! -name 'zddc-server_*.html' 2>/dev/null)
_signed=0 _signed=0
@ -388,22 +377,22 @@ sign_release_artifacts() {
} }
# --- Promote zddc-server release artifacts --------------------------------- # --- Promote zddc-server release artifacts ---------------------------------
# On a stable cut, copy the freshly cross-compiled binaries to the # On a channel/release cut, copy the freshly cross-compiled binaries to
# release-output bundle under their canonical names + symlinks. # the website worktree's releases/ under their canonical names +
# promote_zddc_server also re-runs write_zddc_server_stubs_all # symlinks. promote_zddc_server also re-runs write_zddc_server_stubs_all
# internally, so the per-version + canonical stub pages get regenerated # internally, so the matrix-cell stub pages get regenerated in the same
# in the same call. Beta cuts produce no public binary artifact. # call. On a plain dev build, skip — we don't touch the worktree.
if [ "$RELEASE_CHANNEL" = "stable" ]; then if [ -n "$RELEASE_CHANNEL" ]; then
echo "" echo ""
echo "=== Promoting zddc-server stable release ===" echo "=== Promoting zddc-server $RELEASE_CHANNEL release ==="
promote_zddc_server "stable" "$RELEASE_VERSION" "$RELEASES_DIR" "$SCRIPT_DIR/zddc/dist" promote_zddc_server "$RELEASE_CHANNEL" "$RELEASE_VERSION" "$RELEASES_DIR" "$SCRIPT_DIR/zddc/dist"
fi fi
# Latest stable version, by following archive.html → versioned target. # Latest stable version, by following archive_stable.html → versioned target.
# Returns "" if no stable cut exists yet (bootstrap state). All HTML tools # Returns "" if no stable cut exists yet (bootstrap state). All HTML tools
# move in lockstep so any one of them is a valid probe; archive is canonical. # move in lockstep so any one of them is a valid probe; archive is canonical.
_latest_stable_version() { _latest_stable_version() {
_link="$RELEASES_DIR/archive.html" _link="$RELEASES_DIR/archive_stable.html"
[ -L "$_link" ] || return 0 [ -L "$_link" ] || return 0
_target=$(readlink "$_link") _target=$(readlink "$_link")
# archive_v0.0.8.html → 0.0.8 # archive_v0.0.8.html → 0.0.8
@ -414,6 +403,18 @@ _latest_stable_version() {
esac esac
} }
# Channel "active" iff the channel mirror is real bytes rather than a
# symlink → stable. Used to surface alpha/beta in the dropdown only when
# they meaningfully differ from stable. Probes archive (HTML lockstep
# representative); zddc-server's probe is its per-platform binary.
_channel_is_active() {
_ch="$1" # alpha | beta
_f="$RELEASES_DIR/archive_${_ch}.html"
[ -L "$_f" ] && return 1 # symlink → tracks stable, not "active"
[ -f "$_f" ] && return 0
return 1
}
# Regenerate website/releases/index.html as the action-first install # Regenerate website/releases/index.html as the action-first install
# guide (not a matrix). The page guides users to either self-host the # guide (not a matrix). The page guides users to either self-host the
# server or download individual tools, with one version dropdown that # server or download individual tools, with one version dropdown that
@ -449,6 +450,9 @@ build_releases_index() {
| sort -Vr | sort -Vr
) )
_alpha_active="0"; _channel_is_active alpha && _alpha_active="1"
_beta_active="0"; _channel_is_active beta && _beta_active="1"
{ {
cat <<HEAD cat <<HEAD
<!DOCTYPE html> <!DOCTYPE html>
@ -499,21 +503,39 @@ build_releases_index() {
<select id="version-picker"> <select id="version-picker">
HEAD HEAD
# "latest" — the canonical URL <tool>.html, a symlink that always # Channels — selectable directly so users can copy the channel-
# follows the most recently cut stable. Use this when you want # mirror URLs (e.g. archive_stable.html) for bookmarks. stable is
# auto-updates. Default option so the page works fully without JS. # the default. The label tells the truth about the channel's
# current state: when stable is set, show which version it points
# at; when alpha/beta is just a symlink to stable, mark as
# "tracks stable" so picking it isn't surprising.
printf ' <optgroup label="Channels (mutable URLs)">\n'
if [ -n "$_latest" ] && [ "$_latest" != "0.0.0" ]; then if [ -n "$_latest" ] && [ "$_latest" != "0.0.0" ]; then
printf ' <option value="latest" selected>latest stable — currently v%s</option>\n' "$_latest" printf ' <option value="stable" selected>stable — currently v%s</option>\n' "$_latest"
else else
printf ' <option value="latest" selected>latest stable</option>\n' printf ' <option value="stable" selected>stable</option>\n'
fi fi
if [ "$_beta_active" = "1" ]; then
printf ' <option value="beta">beta — general testing</option>\n'
else
printf ' <option value="beta">beta — tracks stable</option>\n'
fi
if [ "$_alpha_active" = "1" ]; then
printf ' <option value="alpha">alpha — active dev</option>\n'
else
printf ' <option value="alpha">alpha — tracks stable</option>\n'
fi
printf ' </optgroup>\n'
# Pinned per-version, latest first. Immutable URLs — pin one # Pinned per-version, latest first. These are the immutable URLs
# into your archive when you depend on a specific behavior. # for reproducibility. No "(current stable)" suffix because the
# stable channel above already covers that.
printf ' <optgroup label="Pinned versions (immutable URLs)">\n'
printf '%s\n' "$_all_versions" | while read -r _v; do printf '%s\n' "$_all_versions" | while read -r _v; do
[ -n "$_v" ] || continue [ -n "$_v" ] || continue
printf ' <option value="v%s">v%s (pinned)</option>\n' "$_v" "$_v" printf ' <option value="v%s">v%s</option>\n' "$_v" "$_v"
done done
printf ' </optgroup>\n'
cat <<'PICKER_END' cat <<'PICKER_END'
</select> </select>
@ -530,24 +552,24 @@ PICKER_END
# at least once. Until then, show an honest "not yet released" # at least once. Until then, show an honest "not yet released"
# placeholder rather than dangling download buttons. # placeholder rather than dangling download buttons.
_zs_published="0" _zs_published="0"
if [ -e "$RELEASES_DIR/zddc-server_linux-amd64" ]; then if [ -e "$RELEASES_DIR/zddc-server_stable_linux-amd64" ]; then
_zs_published="1" _zs_published="1"
fi fi
if [ "$_zs_published" = "1" ]; then if [ "$_zs_published" = "1" ]; then
# Default href is the canonical per-platform URL # Default href is the channel-mirror URL (zddc-server_stable_<plat>)
# (zddc-server_<plat>), a symlink that always points at the # because "stable" is the dropdown's selected option. Picking a
# latest stable. Picking a pinned version from the dropdown # pinned version from the dropdown rewrites these to the
# rewrites these to the immutable per-version URL via JS. # immutable per-version URL via the IIFE.
printf ' <a class="dl-primary"\n' printf ' <a class="dl-primary"\n'
printf ' data-tool="zddc-server"\n' printf ' data-tool="zddc-server"\n'
printf ' data-platform="linux-amd64"\n' printf ' data-platform="linux-amd64"\n'
printf ' href="zddc-server_linux-amd64"\n' printf ' href="zddc-server_stable_linux-amd64"\n'
printf ' id="dl-primary-binary">\n' printf ' id="dl-primary-binary">\n'
printf ' <span class="dl-icon">⬇</span>\n' printf ' <span class="dl-icon">⬇</span>\n'
printf ' <span>Download <span id="dl-primary-platlabel">for Linux (x86_64)</span></span>\n' printf ' <span>Download <span id="dl-primary-platlabel">for Linux (x86_64)</span></span>\n'
printf ' </a>\n' printf ' </a>\n'
printf ' <span class="dl-primary-meta" id="dl-primary-meta">zddc-server_linux-amd64</span>\n' printf ' <span class="dl-primary-meta" id="dl-primary-meta">zddc-server_stable_linux-amd64</span>\n'
printf ' <div class="dl-secondary-row" id="dl-others">\n' printf ' <div class="dl-secondary-row" id="dl-others">\n'
printf ' <span>Other platforms:</span>\n' printf ' <span>Other platforms:</span>\n'
@ -559,7 +581,7 @@ PICKER_END
_label="${_entry#*|}" _label="${_entry#*|}"
_suffix="" _suffix=""
case "$_plat" in *windows*) _suffix=".exe" ;; esac case "$_plat" in *windows*) _suffix=".exe" ;; esac
printf ' <a data-tool="zddc-server" data-platform="%s" href="zddc-server_%s%s">%s</a>\n' \ printf ' <a data-tool="zddc-server" data-platform="%s" href="zddc-server_stable_%s%s">%s</a>\n' \
"$_plat" "$_plat" "$_suffix" "$_label" "$_plat" "$_plat" "$_suffix" "$_label"
done done
printf ' </div>\n' printf ' </div>\n'
@ -600,9 +622,9 @@ PATH_B_OPEN
_rest="${_entry#*|}" _rest="${_entry#*|}"
_name="${_rest%%|*}" _name="${_rest%%|*}"
_desc="${_rest#*|}" _desc="${_rest#*|}"
# Default href is the canonical symlink <tool>.html; the # Default href is the stable-channel mirror; the dropdown
# dropdown rewires these per selection. # rewires these per selection.
printf ' <a class="tool-card" data-tool="%s" href="%s.html">\n' "$_t" "$_t" printf ' <a class="tool-card" data-tool="%s" href="%s_stable.html">\n' "$_t" "$_t"
printf ' <span class="tool-card__title">%s</span>\n' "$_name" printf ' <span class="tool-card__title">%s</span>\n' "$_name"
printf ' <span class="tool-card__desc">%s</span>\n' "$_desc" printf ' <span class="tool-card__desc">%s</span>\n' "$_desc"
printf ' <span class="tool-card__link">Download &rarr;</span>\n' printf ' <span class="tool-card__link">Download &rarr;</span>\n'
@ -695,12 +717,12 @@ PIN_MID
</div> </div>
<div class="pin-card"> <div class="pin-card">
<h3>Verify a download</h3> <h3>Verify a download</h3>
<p>Each artifact has a matching <code class="inline">.sig</code> file alongside it (<code class="inline">archive.html</code> &rarr; <code class="inline">archive.html.sig</code>, etc.). Fetch both, then:</p> <p>Each artifact has a matching <code class="inline">.sig</code> file alongside it (<code class="inline">archive_stable.html</code> &rarr; <code class="inline">archive_stable.html.sig</code>, etc.). Fetch both, then:</p>
<pre>curl -O https://zddc.varasys.io/releases/archive.html <pre>curl -O https://zddc.varasys.io/releases/archive_stable.html
curl -O https://zddc.varasys.io/releases/archive.html.sig curl -O https://zddc.varasys.io/releases/archive_stable.html.sig
openssl pkeyutl -verify -pubin -inkey pubkey.pem \ openssl pkeyutl -verify -pubin -inkey pubkey.pem \
-rawin -in archive.html \ -rawin -in archive_stable.html \
-sigfile archive.html.sig</pre> -sigfile archive_stable.html.sig</pre>
<p style="font-size: 0.85rem;">Output is <code class="inline">Signature Verified Successfully</code> on a clean download. Any other output (or no output and a non-zero exit) means the bytes do not match the published signature — do not trust them.</p> <p style="font-size: 0.85rem;">Output is <code class="inline">Signature Verified Successfully</code> on a clean download. Any other output (or no output and a non-zero exit) means the bytes do not match the published signature — do not trust them.</p>
</div> </div>
</div> </div>
@ -723,8 +745,7 @@ ZDDC_ROOT=/srv/zddc ./zddc-server</pre>
<pre># &lt;ZDDC_ROOT&gt;/.zddc <pre># &lt;ZDDC_ROOT&gt;/.zddc
admins: [you@yourcompany.com] admins: [you@yourcompany.com]
acl: acl:
permissions: allow: ["*@yourcompany.com"]
'*@yourcompany.com': r
apps_pubkey: | apps_pubkey: |
-----BEGIN PUBLIC KEY----- -----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAXXaxIUIyOFnhD1eZs02nEt3xZ8izOi7bURFcpJ9iWZY= MCowBQYDK2VwAyEAXXaxIUIyOFnhD1eZs02nEt3xZ8izOi7bURFcpJ9iWZY=
@ -735,6 +756,25 @@ apps_pubkey: |
<p style="margin-top: var(--spacing-md); font-size: 0.9rem; color: var(--color-text-muted);">When configured, the resolver fetches the <code class="inline">.sig</code> automatically on every URL-pinned <code class="inline">apps:</code> entry and rejects any unsigned or invalid-signature artifact, falling back to the embedded copy. Operators enforcing signature verification on locally-saved artifacts (Path A, "drop a copy into your archive") run the <code class="inline">openssl</code> verify command above as part of their save workflow.</p> <p style="margin-top: var(--spacing-md); font-size: 0.9rem; color: var(--color-text-muted);">When configured, the resolver fetches the <code class="inline">.sig</code> automatically on every URL-pinned <code class="inline">apps:</code> entry and rejects any unsigned or invalid-signature artifact, falling back to the embedded copy. Operators enforcing signature verification on locally-saved artifacts (Path A, "drop a copy into your archive") run the <code class="inline">openssl</code> verify command above as part of their save workflow.</p>
</section> </section>
<!-- ───────────── Channels explainer ───────────── -->
<section class="card" style="border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-xl); margin-bottom: var(--spacing-xl);">
<h2 style="margin-top:0;">Channels</h2>
<p>Three channels, applied in lockstep across all tools. Pre-release channels exist to soak changes; <strong>stable</strong> is what production runs.</p>
<div class="channel-explainer">
<div>
<h4 class="alpha">alpha</h4>
<p>Active dev iteration. Rebuilds without notice. Look here for the very latest.</p>
</div>
<div>
<h4 class="beta">beta</h4>
<p>Ready for general testing. Has soaked through alpha. Still mutable — pin to a versioned URL for reproducibility.</p>
</div>
<div>
<h4 class="stable">stable</h4>
<p>Ready to ship. Every per-version file is immutable; <code>_stable</code> follows the latest cut. Channel cuts cascade: stable cut resets beta and alpha to track stable.</p>
</div>
</div>
</section>
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
@ -769,21 +809,18 @@ apps_pubkey: |
var primaryMeta = document.getElementById('dl-primary-meta'); var primaryMeta = document.getElementById('dl-primary-meta');
var others = document.getElementById('dl-others'); var others = document.getElementById('dl-others');
function platBinaryName(slug, plat) { function isChannel(v) {
// slug === "latest" → canonical symlink zddc-server_<plat>; return v === 'stable' || v === 'beta' || v === 'alpha';
// slug === "v<X.Y.Z>" → immutable per-version zddc-server_v<X.Y.Z>_<plat>.
var suf = (plat.indexOf('windows') === 0) ? '.exe' : '';
if (slug === 'latest') {
return 'zddc-server_' + plat + suf;
} }
function platBinaryName(slug, plat) {
// slug is a channel name ("stable") or a pinned version ("v0.0.8").
// The on-disk name uses the slug as-is in both cases since the
// channel-mirror filenames are zddc-server_<channel>_<plat> and
// per-version are zddc-server_v<X.Y.Z>_<plat>.
var suf = (plat.indexOf('windows') === 0) ? '.exe' : '';
return 'zddc-server_' + slug + '_' + plat + suf; return 'zddc-server_' + slug + '_' + plat + suf;
} }
function htmlAssetName(tool, slug) { function htmlAssetName(tool, slug) {
// slug === "latest" → canonical symlink <tool>.html;
// slug === "v<X.Y.Z>" → immutable per-version <tool>_v<X.Y.Z>.html.
if (slug === 'latest') {
return tool + '.html';
}
return tool + '_' + slug + '.html'; return tool + '_' + slug + '.html';
} }
@ -801,25 +838,21 @@ apps_pubkey: |
} }
// Single source of truth: the dropdown's current value drives every // Single source of truth: the dropdown's current value drives every
// download link's href. Static markup ships with the canonical // download link's href. Static markup ships with the stable-channel
// URLs (`<tool>.html`, `zddc-server_<plat>`) so the page works // mirror (`<tool>_stable.html`, `zddc-server_stable_<plat>`) so the
// fully without JS — the JS just keeps things in sync when the // page works fully without JS — the JS just keeps things in sync
// user pins a specific version. // when the user picks a different channel or pins a version.
var picker = document.getElementById('version-picker'); var picker = document.getElementById('version-picker');
if (!picker) return; if (!picker) return;
function rewire(slug) { function rewire(slug) {
// slug === "latest" | "v<X.Y.Z>". Every link with a data-tool // slug ∈ {"stable", "beta", "alpha"} | "v<X.Y.Z>". Every link with
// attribute is a download URL the dropdown owns. // a data-tool attribute is a download URL the dropdown owns.
document.querySelectorAll('[data-tool]').forEach(function(a) { document.querySelectorAll('[data-tool]').forEach(function(a) {
var tool = a.dataset.tool; var tool = a.dataset.tool;
var plat = a.dataset.platform || ''; var plat = a.dataset.platform || '';
if (tool === 'zddc-server') { if (tool === 'zddc-server') {
if (plat) { a.href = plat ? platBinaryName(slug, plat) : ('zddc-server_' + slug + '.html');
a.href = platBinaryName(slug, plat);
} else {
a.href = (slug === 'latest') ? 'zddc-server.html' : ('zddc-server_' + slug + '.html');
}
} else { } else {
a.href = htmlAssetName(tool, slug); a.href = htmlAssetName(tool, slug);
} }
@ -858,17 +891,13 @@ apps_pubkey: |
var optionsHTML = picker.innerHTML; var optionsHTML = picker.innerHTML;
selects.forEach(function(sel) { selects.forEach(function(sel) {
sel.innerHTML = optionsHTML; sel.innerHTML = optionsHTML;
sel.value = 'latest'; // default per-app sel.value = 'stable'; // default per-app
}); });
function rebuild() { function rebuild() {
// The picker uses "latest" as its sentinel; the .zddc apps:
// cascade resolves "stable" the same way (follow the upstream
// canonical URL), so emit "stable" for the YAML user.
var lines = ['apps:']; var lines = ['apps:'];
selects.forEach(function(sel) { selects.forEach(function(sel) {
var val = (sel.value === 'latest') ? 'stable' : sel.value; lines.push(' ' + sel.dataset.app + ': ' + sel.value);
lines.push(' ' + sel.dataset.app + ': ' + val);
}); });
textarea.value = lines.join('\n') + '\n'; textarea.value = lines.join('\n') + '\n';
} }
@ -905,9 +934,9 @@ PIN_END
echo "Wrote $_out" echo "Wrote $_out"
} }
# Sign artifacts + regenerate releases/index.html on stable cuts. # Matrix index + verifier only run when we touched the website
# Beta cuts produce no public artifact, so nothing to sign or index. # worktree. Dev builds leave the worktree alone.
if [ "$RELEASE_CHANNEL" = "stable" ]; then if [ -n "$RELEASE_CHANNEL" ]; then
echo "" echo ""
echo "=== Signing release artifacts ===" echo "=== Signing release artifacts ==="
sign_release_artifacts "$RELEASES_DIR" sign_release_artifacts "$RELEASES_DIR"
@ -915,6 +944,10 @@ if [ "$RELEASE_CHANNEL" = "stable" ]; then
echo "" echo ""
echo "=== Building releases/index.html ===" echo "=== Building releases/index.html ==="
build_releases_index build_releases_index
echo ""
echo "=== Verifying channel links ==="
verify_channel_links "$RELEASES_DIR"
fi fi
# --- Embedded commit (stable + beta cuts) --------------------------------- # --- Embedded commit (stable + beta cuts) ---------------------------------
@ -923,11 +956,12 @@ fi
# #
# 1. Stable: the next tag block needs HEAD to point at the bytes the # 1. Stable: the next tag block needs HEAD to point at the bytes the
# stable binary will serve. Without this commit, tags would land on # stable binary will serve. Without this commit, tags would land on
# the source-side commit (with stale embedded/*) and prod images # the source-side commit (with alpha-dirty embedded/*) and prod
# compiled from `git checkout zddc-server-vX.Y.Z` would ship stale # images compiled from `git checkout zddc-server-vX.Y.Z` would
# bytes. (Original justification — preserved.) # ship alpha bytes. (Original justification — preserved.)
# #
# 2. Beta: the dev chart pipeline pins appVersion to a SHA. For that # 2. Beta: the dev pipeline pins the chart's appVersion to a SHA
# (.forgejo/scripts/notify-chart-bump.sh reads HEAD). For that
# pin to point at a SHA where embedded/* matches what the binary # pin to point at a SHA where embedded/* matches what the binary
# will serve, HEAD has to advance past the source-side commit. # will serve, HEAD has to advance past the source-side commit.
# Without this commit, the chart pin lags one commit and the dev # Without this commit, the chart pin lags one commit and the dev
@ -1003,8 +1037,7 @@ if [ -z "$RELEASE_CHANNEL" ]; then
echo " tool/dist/*.html ready" echo " tool/dist/*.html ready"
echo " zddc/dist/zddc-server-* binaries ready" echo " zddc/dist/zddc-server-* binaries ready"
echo "" echo ""
echo "For an internal SHA snapshot (BMC dev chart): ./build beta" echo "To cut alpha into a deployable bundle: ./build alpha"
echo "To cut a stable release: ./build release"
else else
echo "Cut: $RELEASE_CHANNEL" echo "Cut: $RELEASE_CHANNEL"
if [ -n "$RELEASE_VERSION" ]; then if [ -n "$RELEASE_VERSION" ]; then

5
deploy
View file

@ -62,13 +62,10 @@ if [ "$WHAT" = content ] || [ "$WHAT" = all ]; then
echo "=== Syncing content: $CONTENT_SRC/ → $LIVE/ ===" echo "=== Syncing content: $CONTENT_SRC/ → $LIVE/ ==="
# --exclude=/releases/ keeps the live site's releases dir untouched # --exclude=/releases/ keeps the live site's releases dir untouched
# by content syncs. --exclude=.git so the .git dir doesn't end up # by content syncs. --exclude=.git so the .git dir doesn't end up
# under /usr/share/caddy. --exclude=.claude keeps local Claude Code # under /usr/share/caddy.
# tooling state (settings.json, settings.local.json, etc.) off the
# public site.
rsync -av --delete-after \ rsync -av --delete-after \
--exclude='/releases/' \ --exclude='/releases/' \
--exclude='/.git*' \ --exclude='/.git*' \
--exclude='/.claude/' \
--exclude='/README.md' \ --exclude='/README.md' \
--exclude='/LICENSE' \ --exclude='/LICENSE' \
"$CONTENT_SRC/" "$LIVE/" "$CONTENT_SRC/" "$LIVE/"

View file

@ -44,16 +44,7 @@
const help = (ui && ui['ui:help']) || ''; const help = (ui && ui['ui:help']) || '';
const placeholder = (ui && ui['ui:placeholder']) || ''; const placeholder = (ui && ui['ui:placeholder']) || '';
const widget = (ui && ui['ui:widget']) || ''; const widget = (ui && ui['ui:widget']) || '';
// readonly is honored from either source: an explicit UI override const readonly = !!(ui && ui['ui:readonly']);
// (ui:readonly: true) or the schema's readOnly field. The latter
// is set by the server when augmenting from cascade-locked
// records: entries and for audit fields declared readOnly in the
// *.form.yaml.
const readonly = !!(schema.readOnly) || !!(ui && ui['ui:readonly']);
// x-labels: { code → label } turns a bare enum into a labeled
// dropdown ("ACM — Acme Inc" rather than just "ACM"). Injected
// by the server from the cascade's field_codes:codes map.
const labels = (schema && schema['x-labels']) || null;
const autofocus = !!(ui && ui['ui:autofocus']); const autofocus = !!(ui && ui['ui:autofocus']);
let input; let input;
@ -99,22 +90,17 @@
if (widget === 'radio') { if (widget === 'radio') {
input = u.h('div', { className: 'form-field__radio-group' }); input = u.h('div', { className: 'form-field__radio-group' });
opts.forEach(function (opt, idx) { opts.forEach(function (opt, idx) {
const codeStr = String(opt);
const radioId = id + '-' + idx; const radioId = id + '-' + idx;
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: codeStr }); const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: String(opt) });
if (value === opt) { if (value === opt) {
radio.checked = true; radio.checked = true;
} }
if (readonly) { if (readonly) {
radio.disabled = true; radio.disabled = true;
} }
let displayText = codeStr;
if (labels && Object.prototype.hasOwnProperty.call(labels, codeStr)) {
displayText = codeStr + ' — ' + labels[codeStr];
}
const lbl = u.h('label', { for: radioId }); const lbl = u.h('label', { for: radioId });
lbl.appendChild(radio); lbl.appendChild(radio);
lbl.appendChild(document.createTextNode(' ' + displayText)); lbl.appendChild(document.createTextNode(' ' + String(opt)));
input.appendChild(lbl); input.appendChild(lbl);
}); });
read = function () { read = function () {
@ -127,12 +113,7 @@
input.appendChild(u.h('option', { value: '' }, '— select —')); input.appendChild(u.h('option', { value: '' }, '— select —'));
} }
opts.forEach(function (opt) { opts.forEach(function (opt) {
const codeStr = String(opt); const o = u.h('option', { value: String(opt) }, String(opt));
let displayText = codeStr;
if (labels && Object.prototype.hasOwnProperty.call(labels, codeStr)) {
displayText = codeStr + ' — ' + labels[codeStr];
}
const o = u.h('option', { value: codeStr }, displayText);
if (value === opt) { if (value === opt) {
o.selected = true; o.selected = true;
} }
@ -203,12 +184,6 @@
if (autofocus) { if (autofocus) {
input.autofocus = true; input.autofocus = true;
} }
// Schema-driven HTML pattern attribute. Used as a UX hint
// only — authoritative validation runs server-side via the
// cascade's field_codes.
if (schema.pattern && input.tagName === 'INPUT') {
input.pattern = schema.pattern;
}
read = function () { read = function () {
return input.value === '' ? undefined : input.value; return input.value === '' ? undefined : input.value;
}; };

99
freshen-channel Executable file
View file

@ -0,0 +1,99 @@
#!/bin/sh
# =============================================================================
# freshen-channel — rebuild a tool's alpha or beta channel from its current
# stable tag, so users tracking that channel are never on code older than
# current stable.
#
# Usage:
# ./freshen-channel <tool> <channel>
# tool archive | transmittal | classifier | browse | landing | form | tables
# channel alpha | beta
#
# Why this exists:
# Stable releases do NOT automatically clobber alpha/beta files (see
# AGENTS.md "Channel discipline" rule 4). After cutting stable v0.0.5,
# users pinned to alpha may be on an older build than current stable —
# that violates the stale-channel rule. Run this to drag alpha (or
# beta) forward to whatever stable currently is.
#
# What it does:
# 1. Finds the latest <tool>-v* tag.
# 2. Creates a temporary git worktree at that tag — does NOT touch
# your current branch or working tree.
# 3. Runs <tool>/build.sh --release <channel> inside the worktree.
# 4. Copies the resulting <tool>_<channel>.html into the main repo's
# website/releases/.
# 5. Removes the worktree.
#
# The on-page label of the freshened build will be
# `<channel> · <today> · <stable-tag-sha>` — the SHA encodes which
# stable was used as the source, so anyone debugging can `git checkout`
# that exact commit.
#
# Note: the build pipeline used is the one AT THE TAG, not the latest
# main. That is intentional — pure reproducibility. If you have made
# build-system improvements since stable was cut and want the freshen
# to use them, cut a new stable that includes those changes first.
# =============================================================================
set -eu
TOOL="${1:-}"
CHANNEL="${2:-}"
case "$TOOL" in
archive | transmittal | classifier | browse | landing | form | tables) ;;
*)
echo "usage: $0 <tool> <channel>" >&2
echo " tool: archive | transmittal | classifier | browse | landing | form | tables" >&2
exit 1
;;
esac
case "$CHANNEL" in
alpha | beta) ;;
*)
echo "usage: $0 <tool> <channel>" >&2
echo " channel: alpha | beta (stable is what you are freshening FROM)" >&2
exit 1
;;
esac
REPO=$(cd "$(dirname "$0")" && pwd)
# Find the latest stable tag for the tool.
LATEST_TAG=$(git -C "$REPO" tag --list "${TOOL}-v*" --sort=-v:refname | head -1)
if [ -z "$LATEST_TAG" ]; then
echo "error: no stable tag found for ${TOOL} (looking for ${TOOL}-v*)" >&2
echo " cut a stable release first: sh ${TOOL}/build.sh --release [version]" >&2
exit 1
fi
# Temporary detached worktree at the stable tag. Cleaned up on exit.
WT=$(mktemp -d)
cleanup() {
git -C "$REPO" worktree remove --force "$WT" >/dev/null 2>&1 || true
rm -rf "$WT"
}
trap cleanup EXIT INT TERM
echo "Freshening ${TOOL} ${CHANNEL} from ${LATEST_TAG}"
git -C "$REPO" worktree add --quiet --detach "$WT" "$LATEST_TAG"
# Build in the worktree. The tool's build.sh resolves its release dir
# from $ZDDC_DEPLOY_RELEASES_DIR (default $REPO/dist/release-output);
# pass through whatever the parent process has set so freshen-channel
# honors the same target as the regular build.
DEPLOY_DIR="${ZDDC_DEPLOY_RELEASES_DIR:-$REPO/dist/release-output}"
mkdir -p "$DEPLOY_DIR"
ZDDC_DEPLOY_RELEASES_DIR="$DEPLOY_DIR" \
sh "$WT/${TOOL}/build.sh" --release "$CHANNEL"
DST="$DEPLOY_DIR/${TOOL}_${CHANNEL}.html"
if [ ! -f "$DST" ]; then
echo "error: build did not produce $DST" >&2
exit 1
fi
echo "Wrote $DST"
echo "Done. ${CHANNEL} channel for ${TOOL} now reflects ${LATEST_TAG}."
echo "Run ./deploy --releases to push it to the live site."

View file

@ -17,39 +17,42 @@
# inlined JS as containing a closing </script>. # inlined JS as containing a closing </script>.
# The JS engine treats \/ as a regular slash, # The JS engine treats \/ as a regular slash,
# so runtime behaviour is unchanged. # so runtime behaviour is unchanged.
# compute_build_label <tool> [--release [<beta-or-version>]] # compute_build_label <tool> [--release [<channel-or-version>]]
# — sets globals: build_label, build_version, # — sets globals: build_label, build_version,
# is_release, is_red, channel. # is_release, is_red, channel.
# See "Release args" below. # See "Channels and release args" below.
# promote_release <tool> — for stable cuts, copy the dist HTML into # promote_release <tool> — for stable / alpha / beta, copy the dist
# the release-output bundle (default # HTML into the release-output bundle
# $root_dir/../dist/release-output; # (default $root_dir/../dist/release-output;
# override $ZDDC_DEPLOY_RELEASES_DIR). # override $ZDDC_DEPLOY_RELEASES_DIR). Stable
# Writes the immutable per-version file # cuts write the immutable per-version file +
# <tool>_v<X.Y.Z>.html plus the canonical # refresh five symlinks (_v<X.Y>, _v<X>,
# symlink <tool>.html pointing at it. # _stable, _beta, _alpha) and tag
# Tagging is centralized in the top-level # <tool>-v<X.Y.Z>. Alpha/beta cuts
# ./build (after the embedded commit). # overwrite the channel mirror in place
# Beta cuts produce NO public artifact — # and cascade alpha → beta. No git tags
# they are an internal SHA snapshot for # for alpha/beta cuts. The bundle is a
# the BMC dev chart pipeline; the # complete intended-live snapshot — the
# embedded/* regeneration + chore commit # top-level ./build seeds it from
# in the top-level ./build is the actual # /srv/zddc/releases/ before per-tool
# artifact (chart appVersion pins to that # promote runs, then ./deploy --releases
# SHA, Dockerfile fetches it from git). # rsyncs it back. See ARCHITECTURE.md
# "Channels" for the full table.
# #
# Release args: # Channels and release args:
# <none> dev build, tool/dist/ only, label # <none> dev build, tool/dist/ only, label
# "v<next-stable>-dev · <ts> · <sha>[-dirty]" (red). # "v<next-stable>-alpha · <ts> · <sha>[-dirty]" (red).
# No release-output side-effect. # No release-output side-effect. To produce a deployable
# --release stable cut, auto-bump patch from latest tag (or 0.0.1). # bundle, re-run with `--release alpha`.
# Writes <tool>_v<X.Y.Z>.html + <tool>.html symlink; # --release stable, auto-bump patch from latest tag (or 0.0.1).
# tagged later by ./build. # Writes per-version file + symlinks; tags vX.Y.Z.
# --release X.Y.Z stable cut, explicit version. # --release X.Y.Z stable, explicit version.
# --release beta internal SHA snapshot for the BMC dev chart. Build # --release alpha alpha channel cut at HEAD;
# label is "v<next-stable>-beta · <date> · <sha>"; # label "v<next-stable>-alpha · <date> · <sha>" (red).
# no public artifact, no tag. The top-level ./build # Overwrites <tool>_alpha.html. No tag.
# regenerates zddc/internal/apps/embedded/ + commits. # --release beta beta channel; label "v<next-stable>-beta · <date> · <sha>".
# Overwrites <tool>_beta.html. Cascades <tool>_alpha.html
# → <tool>_beta.html (symlink). No tag.
# --release <other> error. # --release <other> error.
# ============================================================================= # =============================================================================
@ -114,7 +117,7 @@ escape_js_close_tags() {
_validate_semver() { _validate_semver() {
_v="$1" _v="$1"
_bad() { _bad() {
echo "error: invalid release argument: '$_v' (expected: beta, or X.Y.Z stable version)" >&2 echo "error: invalid release argument: '$_v' (expected: alpha, beta, or X.Y.Z stable version)" >&2
exit 1 exit 1
} }
_v1="${_v%%.*}" _v1="${_v%%.*}"
@ -169,25 +172,28 @@ _source_commit_short_sha() {
} }
# Compute build label and channel. Reads positional args: # Compute build label and channel. Reads positional args:
# compute_build_label <tool_name> [--release [<beta-or-version>]] # compute_build_label <tool_name> [--release [<channel-or-version>]]
# Sets global variables: # Sets global variables:
# build_label — text rendered into the page's {{BUILD_LABEL}} slot # build_label — text rendered into the page's {{BUILD_LABEL}} slot
# build_version — bare semver string (stable releases only) # build_version — bare semver string (stable releases only)
# is_release — "1" for any --release invocation, else "0" # is_release — "1" for any --release invocation, else "0"
# is_red — "1" if the label should render red+bold (dev/beta), else "0" # is_red — "1" if the label should render red+bold (dev/alpha/beta), else "0"
# channel — "stable" / "beta" / "dev" # channel — "stable" / "alpha" / "beta" / "" (dev)
# #
# Versioning: pre-release semver. The next-stable target is computed from # Versioning: pre-release semver. The next-stable target is computed from
# the latest clean tool-vX.Y.Z tag (patch-bump). Plain dev builds and # the latest clean tool-vX.Y.Z tag (patch-bump). Plain builds and
# `--release beta` carry the next-stable target as a pre-release suffix # `--release alpha`/`--release beta` carry the next-stable target as a
# in the on-page label so users can see which stable the snapshot is # pre-release suffix in the on-page label so users can see which stable
# working toward. Stable releases write a clean vX.Y.Z label and tag. # the alpha/beta is working toward. Stable releases write a clean
# vX.Y.Z label and tag.
# #
# HTML tools do NOT tag beta cuts — beta produces no public artifact # HTML tools do NOT tag alpha/beta cuts (consistent with current
# (the chart pins by SHA via appVersion). Plain dev builds and beta # behavior — alpha and beta artifacts are mutable files, not immutable
# cuts share the same on-page label format (full UTC timestamp + short # per-build snapshots). Plain dev builds and `--release alpha|beta`
# source SHA). A plain dev build may carry a "-dirty" SHA suffix when # cuts share the same on-page label format — full UTC timestamp + short
# the working tree has uncommitted changes; release cuts don't. # source SHA — so testers see one rendering shape regardless of how the
# build was produced. A plain dev build may carry a "-dirty" SHA suffix
# when the working tree has uncommitted changes; release cuts don't.
compute_build_label() { compute_build_label() {
_tool="$1" _tool="$1"
_flag="${2:-}" _flag="${2:-}"
@ -202,17 +208,17 @@ compute_build_label() {
_next_stable=$(_next_stable_for_tool "$_tool") _next_stable=$(_next_stable_for_tool "$_tool")
if [ "$_flag" != "--release" ]; then if [ "$_flag" != "--release" ]; then
# Plain builds are dev iteration — tool/dist/ only, no release # Plain builds are dev builds — labeled as the alpha channel because
# output. The label includes the next-stable target so a developer # that's what the next formal cut would produce, but no Codeberg upload
# opening the local dist file can see which version-in-progress # happens until `--release alpha` is invoked. Full timestamp (granular
# they're looking at. Full timestamp + dirty marker distinguish # than date) and -dirty marker distinguish iterative dev builds from
# iterative dev builds from formal cuts. # formal `--release alpha` cuts (which stamp date-only).
_sha=$(git -C "$root_dir" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown") _sha=$(git -C "$root_dir" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown")
if ! git -C "$root_dir" diff --quiet HEAD 2>/dev/null; then if ! git -C "$root_dir" diff --quiet HEAD 2>/dev/null; then
_sha="${_sha}-dirty" _sha="${_sha}-dirty"
fi fi
channel="dev" channel="alpha"
build_label="v${_next_stable}-dev · ${build_timestamp} · ${_sha}" build_label="v${_next_stable}-alpha · ${build_timestamp} · ${_sha}"
_emit_build_label_sidecar "$_tool" _emit_build_label_sidecar "$_tool"
return 0 return 0
fi fi
@ -220,16 +226,14 @@ compute_build_label() {
is_release=1 is_release=1
case "$_arg" in case "$_arg" in
beta) alpha | beta)
channel="beta" channel="$_arg"
# Internal SHA snapshot for the BMC dev chart. The chart's # Full UTC timestamp + short source SHA — same format as
# appVersion gets set to "<next>-beta-<sha>" and the # plain dev builds. _source_commit_short_sha walks past
# Dockerfile parses the suffix to fetch this SHA from git. # any `chore(embedded): cut …` auto-commit at HEAD so a
# _source_commit_short_sha walks past any `chore(embedded): # re-cut on unchanged source produces the same SHA.
# cut …` auto-commit at HEAD so a re-cut on unchanged
# source produces the same SHA.
_sha=$(_source_commit_short_sha) _sha=$(_source_commit_short_sha)
build_label="v${_next_stable}-beta · ${build_timestamp} · ${_sha}" build_label="v${_next_stable}-${channel} · ${build_timestamp} · ${_sha}"
_emit_build_label_sidecar "$_tool" _emit_build_label_sidecar "$_tool"
return 0 return 0
;; ;;
@ -262,14 +266,14 @@ _emit_build_label_sidecar() {
# Tools that participate in the lockstep release. Source of truth — used # Tools that participate in the lockstep release. Source of truth — used
# by helpers that enumerate "all release artifacts" (matrix render, # by helpers that enumerate "all release artifacts" (matrix render,
# coordinated next-stable). # coordinated next-stable, channel-link verifier).
ZDDC_RELEASE_TOOLS="archive transmittal classifier landing form tables browse zddc-server" ZDDC_RELEASE_TOOLS="archive transmittal classifier landing form tables browse zddc-server"
# Compute the next-stable target for a single tool — patch-bump of its own # Compute the next-stable target for a single tool — patch-bump of its own
# latest <tool>-vX.Y.Z tag. Used by compute_build_label so a tool's # latest <tool>-vX.Y.Z tag. Used by compute_build_label so a tool's
# on-page label reads against its own history (e.g. a beta cut for a # alpha/beta on-page label still reads against its own history (e.g. an
# tool that's been quiet still labels itself targeting that tool's next # alpha cut for a tool that's been quiet still labels itself targeting that
# stable, even when the lockstep convention is in force). # tool's next stable, even when the lockstep convention is in force).
_next_stable_for_tool() { _next_stable_for_tool() {
_t="$1" _t="$1"
_latest=$(git -C "$root_dir" tag --list "${_t}-v*" 2>/dev/null \ _latest=$(git -C "$root_dir" tag --list "${_t}-v*" 2>/dev/null \
@ -311,22 +315,30 @@ _coordinated_next_stable() {
} }
# Promote a built dist file to the release-output bundle. Reads from caller # Promote a built dist file to the release-output bundle. Reads from caller
# scope: $channel ("stable" / "beta"), $build_version (stable only), # scope: $channel ("stable" / "alpha" / "beta"), $build_version (stable only),
# $output_html, $root_dir. Bundle path resolves from $ZDDC_DEPLOY_RELEASES_DIR # $output_html, $root_dir. Bundle path resolves from $ZDDC_DEPLOY_RELEASES_DIR
# (default $root_dir/../dist/release-output). # (default $root_dir/../dist/release-output).
# #
# Stable cuts: # Stable cuts:
# 1. Copy dist HTML → <bundle>/<tool>_v<X.Y.Z>.html (immutable). # 1. Skip if source unchanged since latest stable tag.
# 2. Refresh canonical symlink: <bundle>/<tool>.html → the new versioned file. # 2. Copy dist HTML → <bundle>/<tool>_v<X.Y.Z>.html (immutable).
# 3. Tag the commit <tool>-v<X.Y.Z> (centralized in the top-level ./build). # 3. Refresh symlinks: _v<X.Y>, _v<X>, _stable, _beta, _alpha all → the
# new versioned file. Cascade rule: stable cut means beta and alpha
# reset to stable (no active dev on either downstream channel).
# 4. Tag the commit <tool>-v<X.Y.Z>.
# #
# Beta cuts: # Alpha/beta cuts:
# No public artifact. The chart's Dockerfile fetches the source at the # 1. Overwrite <bundle>/<tool>_<channel>.html with dist HTML
# SHA pinned in chart appVersion and compiles its own binary; the # (replaces a symlink with real bytes if one was there).
# embedded/* regeneration + chore commit in the top-level ./build is # 2. For beta: cascade <tool>_alpha.html → <tool>_beta.html (symlink),
# the actual snapshot. # since alpha defaults to beta when no active alpha.
# 3. No tag — channel URLs are stable URLs by design; counters defeat
# that. The on-page label encodes <date> · <sha> for traceability.
# #
# Plain dev builds (no --release): never call promote_release. # Plain dev builds (no --release): never call promote_release.
#
# No Codeberg upload — HTML tools live in git. zddc-server's release.sh
# handles binary uploads to Codeberg directly (different distribution model).
promote_release() { promote_release() {
_tool="$1" _tool="$1"
# The top-level `./build` exports $ZDDC_DEPLOY_RELEASES_DIR pointing # The top-level `./build` exports $ZDDC_DEPLOY_RELEASES_DIR pointing
@ -351,16 +363,18 @@ promote_release() {
# every tool, even when a tool's source hasn't changed since # every tool, even when a tool's source hasn't changed since
# its last tag. The bytes are identical (build is deterministic # its last tag. The bytes are identical (build is deterministic
# at the same source), so the overwrite is a no-op on disk; # at the same source), so the overwrite is a no-op on disk;
# but the canonical symlink <tool>.html advances to the new # but the symlink chain (_v<X.Y>, _v<X>, _stable, _beta, _alpha)
# version, which is the actual goal. # gets advanced to the new version, which is the actual goal.
#
# The previous "skip if no source changes since $_latest" check
# was a relic of per-tool independent versioning. It broke
# CI re-cuts at a tag commit (HEAD == latest tag → diff empty
# → skip → dist/release-output/ stays seeded at the previous
# version → deploy publishes the previous version).
_promote_stable "$_tool" "$build_version" "$_releases_dir" _promote_stable "$_tool" "$build_version" "$_releases_dir"
;; ;;
beta) alpha | beta)
# Internal SHA snapshot for the BMC dev chart. No public _promote_channel "$_tool" "$channel" "$_releases_dir"
# artifact: the chart fetches the source at the SHA via git,
# the embedded/* regeneration + chore commit (in the top-
# level ./build) IS the artifact.
echo " ${_tool}: beta is internal (no public artifact)"
;; ;;
*) *)
echo "promote_release: unknown channel '$channel'" >&2 echo "promote_release: unknown channel '$channel'" >&2
@ -369,31 +383,35 @@ promote_release() {
esac esac
} }
# Stable cut: per-version immutable file + canonical symlink. Tagging is # Stable cut: per-version file + 5 symlinks. Tagging is centralized in
# centralized in the top-level ./build (it commits embedded artifacts # the top-level ./build (it commits embedded artifacts FIRST, then tags
# FIRST, then tags at the new commit — see "Release commit + tag" block # at the new commit — see "Release commit + tag" block at the bottom of
# at the bottom of the script). _promote_stable historically created # the script). _promote_stable historically created tags itself, but
# tags itself, but that placed them on the source-side commit before # that placed them on the source-side commit before embedded files were
# embedded files were folded in, leaving prod binaries with stale bytes # folded in, leaving prod binaries with alpha-dirty bytes baked in.
# baked in.
_promote_stable() { _promote_stable() {
_t="$1" _t="$1"
_ver="$2" _ver="$2"
_rdir="$3" _rdir="$3"
_major="${_ver%%.*}"
_rest="${_ver#*.}"
_minor="${_rest%%.*}"
_versioned="${_t}_v${_ver}.html" _versioned="${_t}_v${_ver}.html"
_canonical="${_t}.html"
cp "$output_html" "$_rdir/$_versioned" cp "$output_html" "$_rdir/$_versioned"
echo "Wrote $_rdir/$_versioned" echo "Wrote $_rdir/$_versioned"
ln -sfn "$_versioned" "$_rdir/$_canonical" # Refresh the 5 symlinks. Cascade: stable cut → beta + alpha both
echo " $_canonical$_versioned" # reset to stable (no active dev on either downstream channel).
for _sym in "${_t}_v${_major}.${_minor}.html" \
# Companion .sig symlink so `curl <canonical>.sig` resolves. The "${_t}_v${_major}.html" \
# actual .sig file is written by sign_release_artifacts; this "${_t}_stable.html" \
# symlink points there. "${_t}_beta.html" \
ln -sfn "${_versioned}.sig" "$_rdir/${_canonical}.sig" "${_t}_alpha.html"; do
ln -sfn "$_versioned" "$_rdir/$_sym"
echo " $_sym$_versioned"
done
# Pre-flight check only: if the tag already exists pointing at a # Pre-flight check only: if the tag already exists pointing at a
# commit that is NOT an ancestor of HEAD, the operator needs to # commit that is NOT an ancestor of HEAD, the operator needs to
@ -413,6 +431,28 @@ _promote_stable() {
echo "Released ${_t} v${_ver} (stable; tagging deferred to top-level build)" echo "Released ${_t} v${_ver} (stable; tagging deferred to top-level build)"
} }
# Alpha/beta cut: overwrite mutable channel mirror; cascade alpha → beta
# on a beta cut (alpha defaults to beta when no active alpha).
_promote_channel() {
_t="$1"
_ch="$2"
_rdir="$3"
_file="${_t}_${_ch}.html"
# Replace symlink (if present) with real bytes by removing first;
# cp -f follows symlinks and would overwrite the symlink target.
rm -f "$_rdir/$_file"
cp "$output_html" "$_rdir/$_file"
echo "Wrote $_rdir/$_file"
if [ "$_ch" = "beta" ]; then
ln -sfn "$_file" "$_rdir/${_t}_alpha.html"
echo " ${_t}_alpha.html → $_file"
fi
echo "Released ${_t} ${_ch}"
}
# Platforms zddc-server is cross-compiled for. The first three are # Platforms zddc-server is cross-compiled for. The first three are
# extension-less (Linux/macOS); Windows gets .exe. The build always emits # extension-less (Linux/macOS); Windows gets .exe. The build always emits
# all four; the matrix cell's stub page links each by its <platform> tag. # all four; the matrix cell's stub page links each by its <platform> tag.
@ -430,36 +470,32 @@ _zddc_server_platform_label() {
esac esac
} }
# Resolve a zddc-server binary's filename for one (slug, platform). # Resolve a zddc-server binary's filename for one (version, platform).
# Returns the bare name (no path); ".exe" suffix on windows. Empty slug # Returns the bare name (no path); ".exe" suffix on windows.
# means the canonical "current stable" symlink (zddc-server_<plat>);
# non-empty slug is a per-version asset (zddc-server_v<X.Y.Z>_<plat>).
_zddc_server_binary_name() { _zddc_server_binary_name() {
_slug="$1" _ver_or_chan="$1"
_plat="$2" _plat="$2"
_suffix="" _suffix=""
case "$_plat" in *windows*) _suffix=".exe" ;; esac case "$_plat" in *windows*) _suffix=".exe" ;; esac
if [ -z "$_slug" ]; then if echo "$_ver_or_chan" | grep -qE '^v[0-9]'; then
printf 'zddc-server_%s%s' "$_plat" "$_suffix" # Per-version asset, e.g. zddc-server_v0.0.8_linux-amd64
printf 'zddc-server_%s_%s%s' "$_ver_or_chan" "$_plat" "$_suffix"
else else
printf 'zddc-server_%s_%s%s' "$_slug" "$_plat" "$_suffix" # Channel mirror, e.g. zddc-server_stable_linux-amd64
printf 'zddc-server_%s_%s%s' "$_ver_or_chan" "$_plat" "$_suffix"
fi fi
} }
# Write the small HTML index page that becomes the entry point for a # Write the small HTML index page that becomes the matrix cell's link for
# zddc-server release. Lists each platform binary with a download link. # a zddc-server release. Lists each platform binary with a download link.
# $1 — release directory (absolute) # $1 — release directory (absolute)
# $2 — slug ("" for canonical "current stable", or "v0.0.8" per-version) # $2 — slug (e.g. v0.0.8, v0.0, stable, beta, alpha)
# $3 — display label (e.g. "current stable", "v0.0.8") # $3 — display label (e.g. "v0.0.8", "stable channel")
write_zddc_server_stub() { write_zddc_server_stub() {
_rdir="$1" _rdir="$1"
_slug="$2" _slug="$2"
_label="$3" _label="$3"
if [ -z "$_slug" ]; then
_out="$_rdir/zddc-server.html"
else
_out="$_rdir/zddc-server_${_slug}.html" _out="$_rdir/zddc-server_${_slug}.html"
fi
{ {
cat <<HEAD cat <<HEAD
@ -505,40 +541,68 @@ TAIL
} }
# Refresh every zddc-server stub page based on what's currently in the # Refresh every zddc-server stub page based on what's currently in the
# release-output bundle: one per-version stub per zddc-server_v*_* # release-output bundle. Driven by the existing per-version binary files +
# binary set, plus a canonical zddc-server.html if the latest-stable # symlinks that the release flow already maintains; just emits the HTML
# symlinks are in place. Indexed off linux-amd64 since all four # wrappers for them. Safe to run on every cut (idempotent).
# platforms ship in lockstep.
# #
# $1 — releases dir (absolute) # $1 — releases dir (absolute)
write_zddc_server_stubs_all() { write_zddc_server_stubs_all() {
_rdir="$1" _rdir="$1"
# Per-version stubs (immutable). # Every per-version stable binary that exists. We index off
# linux-amd64 specifically since all four platforms ship in lockstep
# — if the linux build is missing the version is incomplete anyway.
for _bin in "$_rdir"/zddc-server_v*_linux-amd64; do for _bin in "$_rdir"/zddc-server_v*_linux-amd64; do
[ -e "$_bin" ] || continue [ -e "$_bin" ] || continue
_name=$(basename "$_bin") _name=$(basename "$_bin")
# zddc-server_vX.Y.Z_linux-amd64 → vX.Y.Z
_slug=$(echo "$_name" | sed -E 's/^zddc-server_(v[^_]+)_linux-amd64$/\1/') _slug=$(echo "$_name" | sed -E 's/^zddc-server_(v[^_]+)_linux-amd64$/\1/')
# Skip partial-version pins (vX.Y, vX) — these are written
# separately below from symlink resolution.
case "$_slug" in case "$_slug" in
v*.*.*) write_zddc_server_stub "$_rdir" "$_slug" "$_slug" ;; v*.*.*) write_zddc_server_stub "$_rdir" "$_slug" "$_slug" ;;
esac esac
done done
# Canonical stub (follows the latest-stable symlink). Probes the # Partial-version + channel stubs follow the symlink chain. If the
# linux-amd64 canonical name; if it exists, the platform symlinks # symlink resolves to a real binary, write the stub; otherwise skip.
# are in place and we can write the entry page. for _slug in stable beta alpha; do
if [ -e "$_rdir/zddc-server_linux-amd64" ]; then _probe="$_rdir/zddc-server_${_slug}_linux-amd64"
write_zddc_server_stub "$_rdir" "" "current stable" if [ -e "$_probe" ]; then
write_zddc_server_stub "$_rdir" "$_slug" "${_slug} channel"
fi
done
# vX.Y and vX partial pins — derive the slug list from the per-version
# binaries so we only emit pages we actually have artifacts for.
_all_versions=$(find "$_rdir" -maxdepth 1 -name 'zddc-server_v*_linux-amd64' \
| sed -E 's|^.*/zddc-server_(v[0-9]+\.[0-9]+\.[0-9]+)_linux-amd64$|\1|' \
| sort -Vu)
if [ -n "$_all_versions" ]; then
# vX.Y pins — pick the highest patch within each X.Y, then make
# sure the symlink and stub exist.
echo "$_all_versions" | sed -E 's|^v([0-9]+\.[0-9]+)\.[0-9]+$|\1|' | sort -Vu | while read -r _xy; do
_probe="$_rdir/zddc-server_v${_xy}_linux-amd64"
if [ -e "$_probe" ]; then
write_zddc_server_stub "$_rdir" "v${_xy}" "v${_xy}"
fi
done
# vX pins.
echo "$_all_versions" | sed -E 's|^v([0-9]+)\..*$|\1|' | sort -Vu | while read -r _x; do
_probe="$_rdir/zddc-server_v${_x}_linux-amd64"
if [ -e "$_probe" ]; then
write_zddc_server_stub "$_rdir" "v${_x}" "v${_x}"
fi
done
fi fi
} }
# Promote a freshly-cross-compiled set of zddc-server binaries to the # Promote a freshly-cross-compiled set of zddc-server binaries to the
# release-output bundle. Called by the top-level ./build on a stable # release-output bundle. Called by the top-level ./build on a release cut.
# release cut. Beta cuts produce no public artifact (the chart's
# Dockerfile compiles from source at the SHA pinned in appVersion).
# #
# $1 — channel ("stable" | "beta") # $1 — channel ("stable" | "alpha" | "beta")
# $2 — version (X.Y.Z; required for stable; ignored for beta) # $2 — version (X.Y.Z; required for stable; ignored for alpha/beta but
# passed through so labels can include the next-stable target)
# $3 — releases dir (absolute) # $3 — releases dir (absolute)
# $4 — dist dir holding cross-compiled binaries (absolute) # $4 — dist dir holding cross-compiled binaries (absolute)
promote_zddc_server() { promote_zddc_server() {
@ -564,21 +628,27 @@ promote_zddc_server() {
echo "promote_zddc_server: stable cut requires version" >&2 echo "promote_zddc_server: stable cut requires version" >&2
return 1 return 1
fi fi
_major="${_ver%%.*}"
_rest="${_ver#*.}"
_minor="${_rest%%.*}"
# Per-version immutable + canonical per-platform symlink. # Per-version: copy each binary to its immutable name + refresh
# the partial-version + channel symlinks. Mirrors the HTML-tool
# cascade: stable cut → beta + alpha both reset to stable.
for _plat in $ZDDC_SERVER_PLATFORMS; do for _plat in $ZDDC_SERVER_PLATFORMS; do
_suffix="" _suffix=""
case "$_plat" in *windows*) _suffix=".exe" ;; esac case "$_plat" in *windows*) _suffix=".exe" ;; esac
_src="$_dist/zddc-server-${_plat}${_suffix}" _src="$_dist/zddc-server-${_plat}${_suffix}"
_versioned="zddc-server_v${_ver}_${_plat}${_suffix}" _versioned="zddc-server_v${_ver}_${_plat}${_suffix}"
_canonical="zddc-server_${_plat}${_suffix}"
cp "$_src" "$_rdir/$_versioned" cp "$_src" "$_rdir/$_versioned"
echo "Wrote $_rdir/$_versioned" echo "Wrote $_rdir/$_versioned"
ln -sfn "$_versioned" "$_rdir/$_canonical" for _sym in "zddc-server_v${_major}.${_minor}_${_plat}${_suffix}" \
echo " $_canonical$_versioned" "zddc-server_v${_major}_${_plat}${_suffix}" \
# Companion .sig symlink — see _promote_stable for the "zddc-server_stable_${_plat}${_suffix}" \
# same pattern. "zddc-server_beta_${_plat}${_suffix}" \
ln -sfn "${_versioned}.sig" "$_rdir/${_canonical}.sig" "zddc-server_alpha_${_plat}${_suffix}"; do
ln -sfn "$_versioned" "$_rdir/$_sym"
done
done done
# Pre-flight tag check only — actual tagging happens in the # Pre-flight tag check only — actual tagging happens in the
@ -597,11 +667,22 @@ promote_zddc_server() {
fi fi
echo "Released zddc-server v${_ver} (stable; tagging deferred to top-level build)" echo "Released zddc-server v${_ver} (stable; tagging deferred to top-level build)"
;; ;;
beta) alpha | beta)
# Internal SHA snapshot — the chart's Dockerfile fetches the # Mutable channel mirror per platform; cascade alpha → beta on
# source at that SHA and compiles its own binary. No public # a beta cut.
# binary is published. for _plat in $ZDDC_SERVER_PLATFORMS; do
echo " zddc-server: beta is internal (no public artifact)" _suffix=""
case "$_plat" in *windows*) _suffix=".exe" ;; esac
_src="$_dist/zddc-server-${_plat}${_suffix}"
_file="zddc-server_${_ch}_${_plat}${_suffix}"
rm -f "$_rdir/$_file"
cp "$_src" "$_rdir/$_file"
echo "Wrote $_rdir/$_file"
if [ "$_ch" = "beta" ]; then
ln -sfn "$_file" "$_rdir/zddc-server_alpha_${_plat}${_suffix}"
fi
done
echo "Released zddc-server ${_ch}"
;; ;;
*) *)
echo "promote_zddc_server: unknown channel '$_ch'" >&2 echo "promote_zddc_server: unknown channel '$_ch'" >&2
@ -609,6 +690,70 @@ promote_zddc_server() {
;; ;;
esac esac
# Refresh stub pages (per-version + canonical). # Refresh every stub page (covers the new release plus any pre-existing).
write_zddc_server_stubs_all "$_rdir" write_zddc_server_stubs_all "$_rdir"
} }
# Verify every channel link for every release tool exists and resolves.
# Runs at the end of every build. Fails the build if anything is dangling.
# Channel verification covers both HTML tools (one .html per channel) and
# zddc-server (one stub HTML + four binaries per channel).
#
# Bootstrap-friendly: if zddc-server has no per-version artifacts at all
# (i.e. no release has been cut yet under the new lockstep model), the
# zddc-server entries are skipped with a heads-up rather than failing. The
# first stable cut materializes them.
verify_channel_links() {
_rdir="$1"
_missing=0
_verified=0
for _t in archive transmittal classifier landing form tables browse; do
for _ch in stable beta alpha; do
_f="$_rdir/${_t}_${_ch}.html"
if [ -e "$_f" ]; then
_verified=$((_verified + 1))
else
echo " MISSING: ${_t}_${_ch}.html" >&2
_missing=$((_missing + 1))
fi
done
done
# zddc-server's stable cut anchors the channel chain (cascade rule:
# stable cut → alpha + beta both reset to stable). Until stable
# exists, the verifier runs in bootstrap mode and skips — alpha/beta
# cuts in isolation are valid bootstrap state but have no cascade
# fallback target yet.
_zs_stable_exists=$(find "$_rdir" -maxdepth 1 -name 'zddc-server_stable_linux-amd64' -print -quit 2>/dev/null)
if [ -z "$_zs_stable_exists" ]; then
echo " (zddc-server stable not yet cut — run 'sh build.sh --release' to anchor the channel chain)"
else
for _ch in stable beta alpha; do
_f="$_rdir/zddc-server_${_ch}.html"
if [ -e "$_f" ]; then
_verified=$((_verified + 1))
else
echo " MISSING: zddc-server_${_ch}.html" >&2
_missing=$((_missing + 1))
fi
for _plat in $ZDDC_SERVER_PLATFORMS; do
_suffix=""
case "$_plat" in *windows*) _suffix=".exe" ;; esac
_f="$_rdir/zddc-server_${_ch}_${_plat}${_suffix}"
if [ -e "$_f" ]; then
_verified=$((_verified + 1))
else
echo " MISSING: zddc-server_${_ch}_${_plat}${_suffix}" >&2
_missing=$((_missing + 1))
fi
done
done
fi
if [ "$_missing" -gt 0 ]; then
echo "channel-link verification: $_missing missing artifact(s)" >&2
return 1
fi
echo "channel-link verification: $_verified link(s) ok"
}

View file

@ -160,11 +160,7 @@
left-border swatch; the row tooltip on hover surfaces the state. left-border swatch; the row tooltip on hover surfaces the state.
Colors track the state's urgency: dirty (subtle), saving (info), Colors track the state's urgency: dirty (subtle), saving (info),
queued (warm), invalid/stale (warning), errored (alert). */ queued (warm), invalid/stale (warning), errored (alert). */
/* Dirty row gets a wider swatch (4px easier to see at a glance) AND .zddc-table__row--dirty td:first-child { box-shadow: inset 3px 0 0 var(--color-info, #4a90e2); }
a faint blue background so the unsaved state reads as "row is in a
different state" not "small marker on the edge". */
.zddc-table__row--dirty td:first-child { box-shadow: inset 4px 0 0 var(--color-info, #4a90e2); }
.zddc-table__row--dirty { background: var(--color-bg-info, rgba(74, 144, 226, 0.08)); }
.zddc-table__row--saving td:first-child { box-shadow: inset 3px 0 0 var(--color-muted, #888); } .zddc-table__row--saving td:first-child { box-shadow: inset 3px 0 0 var(--color-muted, #888); }
.zddc-table__row--queued td:first-child { box-shadow: inset 3px 0 0 var(--color-warm, #d4a017); } .zddc-table__row--queued td:first-child { box-shadow: inset 3px 0 0 var(--color-warm, #d4a017); }
.zddc-table__row--stale td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); background: var(--color-bg-warning, rgba(232, 163, 61, 0.06)); } .zddc-table__row--stale td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); background: var(--color-bg-warning, rgba(232, 163, 61, 0.06)); }

View file

@ -109,7 +109,6 @@
app.state.drafts[rowId] = {}; app.state.drafts[rowId] = {};
} }
app.state.drafts[rowId][field] = value; app.state.drafts[rowId][field] = value;
notifyDraftsChanged();
} }
function clearDraftField(rowId, field) { function clearDraftField(rowId, field) {
@ -119,17 +118,6 @@
if (Object.keys(r).length === 0) { if (Object.keys(r).length === 0) {
delete app.state.drafts[rowId]; delete app.state.drafts[rowId];
} }
notifyDraftsChanged();
}
// Notify the save module that drafts changed so it can update the
// toolbar Save button + count. Save module is optional in test
// fixtures, so the call is guarded.
function notifyDraftsChanged() {
const save = app.modules.save;
if (save && typeof save.onDraftsChanged === 'function') {
save.onDraftsChanged();
}
} }
function effectiveCellValue(row, col) { function effectiveCellValue(row, col) {

View file

@ -31,7 +31,6 @@
const clearBtn = document.getElementById('table-clear-filters'); const clearBtn = document.getElementById('table-clear-filters');
const addRowBtn = document.getElementById('table-add-row'); const addRowBtn = document.getElementById('table-add-row');
const exportBtn = document.getElementById('table-export-csv'); const exportBtn = document.getElementById('table-export-csv');
const saveBtn = document.getElementById('table-save');
// Add-row button: appends a draft row inline. Save fires on // Add-row button: appends a draft row inline. Save fires on
// row-blur, which POSTs to <dir>/form.html and swaps the // row-blur, which POSTs to <dir>/form.html and swaps the
@ -40,50 +39,6 @@
// context loaded with columns) — the test-fixture inline-context // context loaded with columns) — the test-fixture inline-context
// harness opens tables.html directly with no URL shape, so we // harness opens tables.html directly with no URL shape, so we
// gate on having a column list AND running over http(s). // gate on having a column list AND running over http(s).
// Save: explicit flush of every dirty row. The button is
// hidden until a draft exists; save.onDraftsChanged() (called
// from editor.setDraft / clearDraftField) toggles visibility +
// updates the count label. Backstop for the row-blur trigger,
// which only fires when the user navigates to a different
// ROW in the table — clicking outside the grid entirely never
// fired a save without this.
if (saveBtn) {
saveBtn.addEventListener('click', function () {
const save = app.modules.save;
if (save && typeof save.flushAll === 'function') {
save.flushAll();
}
});
}
// Ctrl+S (Cmd+S on mac) flushes all dirty rows. Capturing
// phase so we beat the browser's "Save Page As" default.
window.addEventListener('keydown', function (ev) {
if ((ev.ctrlKey || ev.metaKey) && (ev.key === 's' || ev.key === 'S')) {
const save = app.modules.save;
if (save && typeof save.dirtyCount === 'function' && save.dirtyCount() > 0) {
ev.preventDefault();
save.flushAll();
}
}
});
// Auto-save when focus leaves the grid entirely (the user
// clicked a header link, the URL bar, etc. without moving to
// another row first). focusout fires for cell-to-cell moves
// too — relatedTarget being outside #table-root distinguishes.
const tableRoot = document.getElementById('table-root');
if (tableRoot) {
tableRoot.addEventListener('focusout', function (ev) {
const next = ev.relatedTarget;
if (next && tableRoot.contains(next)) return;
const save = app.modules.save;
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
save.flushAll();
}
});
}
// Export CSV: client-side build of the current view (filtered + // Export CSV: client-side build of the current view (filtered +
// sorted columns + values). No server round-trip, no auth gate // sorted columns + values). No server round-trip, no auth gate
// — the user already has the data on screen. Shown on every // — the user already has the data on screen. Shown on every
@ -200,11 +155,6 @@
if (save && typeof save.markAllDirtyRows === 'function') { if (save && typeof save.markAllDirtyRows === 'function') {
save.markAllDirtyRows(); save.markAllDirtyRows();
} }
// Refresh the Save button visibility + count after every
// paint — save flow may have settled drafts in the meantime.
if (save && typeof save.updateSaveButton === 'function') {
save.updateSaveButton();
}
} }
// Public re-paint entry point so other modules (save.useMine / // Public re-paint entry point so other modules (save.useMine /

View file

@ -154,29 +154,9 @@
const rangeRows = ctx.rangeRowIds || []; const rangeRows = ctx.rangeRowIds || [];
const inRange = rangeRows.length > 1 && rangeRows.indexOf(ctx.rowId) !== -1; const inRange = rangeRows.length > 1 && rangeRows.indexOf(ctx.rowId) !== -1;
const targets = inRange ? rangeRows : [ctx.rowId]; const targets = inRange ? rangeRows : [ctx.rowId];
const items = [];
// Edit row — opens the schema-driven form-mode editor for
// this row. row.url is already the <id>.yaml.html form URL
// (the form handler unwraps virtual-view URLs server-side, so
// SSR + rollup rows route to their per-party canonical paths
// automatically). Disabled on multi-row range and unsaved
// draft rows (no backing file yet).
const singleRow = targets.length === 1 ? ctx.row : null;
const editUrl = singleRow && !singleRow.isNew && singleRow.url ? singleRow.url : null;
items.push({
label: 'Edit row',
icon: '✎',
disabled: !editUrl,
action: function () {
if (editUrl) window.location.href = editUrl;
}
});
items.push({ separator: true });
const label = targets.length > 1 ? 'Delete ' + targets.length + ' rows' : 'Delete row'; const label = targets.length > 1 ? 'Delete ' + targets.length + ' rows' : 'Delete row';
items.push({ return [
{
label: label, label: label,
icon: '🗑', icon: '🗑',
danger: true, danger: true,
@ -185,9 +165,8 @@
if (targets.length > 1) deleteRows(targets); if (targets.length > 1) deleteRows(targets);
else deleteRow(targets[0]); else deleteRow(targets[0]);
} }
}); }
];
return items;
} }
function onRowContext(ev) { function onRowContext(ev) {

View file

@ -237,30 +237,13 @@
// Success: clear drafts + invalid marks, capture new ETag. // Success: clear drafts + invalid marks, capture new ETag.
const newEtag = resp.headers.get('ETag'); const newEtag = resp.headers.get('ETag');
if (newEtag) row.etag = newEtag.replace(/"/g, ''); if (newEtag) row.etag = newEtag.replace(/"/g, '');
// For record-typed writes the server echoes the stamped row.data = merged;
// YAML (with server-managed audit fields) back as the
// response body — parse it and overwrite row.data so the
// table sees the same bytes that just landed on disk.
// Falls back to the local merge when the server didn't
// echo a body (non-record write or older server).
let serverData = null;
const ct = (resp.headers.get('Content-Type') || '').toLowerCase();
if (ct.includes('yaml') && window.jsyaml) {
try {
const text = await resp.text();
if (text && text.trim()) serverData = window.jsyaml.load(text);
} catch (e) {
console.warn('[tables] server response YAML parse failed; using local merge', e);
}
}
row.data = serverData || merged;
delete app.state.drafts[rowId]; delete app.state.drafts[rowId];
clearCellInvalid(rowId); clearCellInvalid(rowId);
setRowState(rowId, ''); setRowState(rowId, '');
// If a status prompt was up for this row, drop it. // If a status prompt was up for this row, drop it.
const sb = document.getElementById('table-status'); const sb = document.getElementById('table-status');
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus(); if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
updateSaveButton();
return { status: 'ok' }; return { status: 'ok' };
} }
@ -271,7 +254,6 @@
row.data = merged; row.data = merged;
delete app.state.drafts[rowId]; delete app.state.drafts[rowId];
setRowState(rowId, 'queued'); setRowState(rowId, 'queued');
updateSaveButton();
return { status: 'queued' }; return { status: 'queued' };
} }
@ -502,51 +484,6 @@
} }
} }
// flushAll fires saves for every dirty row and returns when they
// all settle. Used by the explicit Save button and the auto-save
// when focus leaves the grid. Unlike flushAllDrafts, this is NOT
// keepalive — the page isn't going anywhere, so we wait for real
// responses and surface errors normally.
async function flushAll() {
const drafts = app.state.drafts || {};
const ids = Object.keys(drafts).filter(id => drafts[id] && Object.keys(drafts[id]).length > 0);
if (ids.length === 0) return { status: 'noop' };
const results = await Promise.allSettled(ids.map(id => saveRow(id)));
const ok = results.filter(r => r.status === 'fulfilled' && r.value && r.value.status === 'ok').length;
return { status: 'done', total: ids.length, ok: ok, failed: ids.length - ok };
}
// Count rows that have at least one unsaved field.
function dirtyCount() {
const drafts = app.state.drafts || {};
let n = 0;
for (const id in drafts) {
if (drafts[id] && Object.keys(drafts[id]).length > 0) n++;
}
return n;
}
// Update the toolbar Save button visibility + label from current
// draft state. Called from editor.js whenever drafts mutate; also
// safe to call anytime (e.g. after a paint).
function updateSaveButton() {
const btn = document.getElementById('table-save');
if (!btn) return;
const n = dirtyCount();
if (n === 0) {
btn.hidden = true;
btn.textContent = 'Save';
return;
}
btn.hidden = false;
btn.textContent = n === 1 ? 'Save (1 unsaved)' : 'Save (' + n + ' unsaved)';
}
function onDraftsChanged() {
updateSaveButton();
markAllDirtyRows();
}
// Window unload handler — call any in-flight drafts so the user // Window unload handler — call any in-flight drafts so the user
// doesn't lose typing on tab-close. The PUT uses keepalive:true so // doesn't lose typing on tab-close. The PUT uses keepalive:true so
// it survives navigation; that comes with a 64 KB body cap. // it survives navigation; that comes with a 64 KB body cap.
@ -559,11 +496,7 @@
useMine: useMine, useMine: useMine,
reload: reload, reload: reload,
onSelectionChanged: onSelectionChanged, onSelectionChanged: onSelectionChanged,
onDraftsChanged: onDraftsChanged,
markAllDirtyRows: markAllDirtyRows, markAllDirtyRows: markAllDirtyRows,
updateSaveButton: updateSaveButton,
flushAll: flushAll,
dirtyCount: dirtyCount,
flushAllDrafts: flushAllDrafts, flushAllDrafts: flushAllDrafts,
}; };
})(window.tablesApp); })(window.tablesApp);

View file

@ -47,7 +47,6 @@
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button> <button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
</div> </div>
<div class="table-toolbar__right"> <div class="table-toolbar__right">
<button type="button" id="table-save" class="btn btn-primary btn-sm" hidden>Save</button>
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button> <button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a> <a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
</div> </div>

View file

@ -1189,14 +1189,13 @@ sidesteps the operator entirely:
**Implementation has three parts** that interlock: **Implementation has three parts** that interlock:
1. **Signing in the build pipeline.** `./build release` runs 1. **Signing in the build pipeline.** `./build alpha|beta|release` runs
`sign_release_artifacts` (in `./build`) after promote: walks `sign_release_artifacts` (in `./build`) after promote: walks
`dist/release-output/` and produces a detached Ed25519 signature `dist/release-output/` and produces a detached Ed25519 signature
(`<artifact>.sig`) alongside every real file. Private key path comes (`<artifact>.sig`) alongside every real file. Private key path comes
from `ZDDC_SIGNING_KEY`; absent or unreadable → release fails. from `ZDDC_SIGNING_KEY`; absent or unreadable → release fails.
Symlinks (the canonical `<tool>.html` and `zddc-server_<platform>` Symlinks (channel mirrors, partial-version pins) skip — the .sig
URLs) skip — the .sig at the symlink target is what counts; a at the symlink target is what counts.
companion `.sig` symlink chains the canonical URL to that target.
2. **Public key on the website.** `pubkey.pem` is a real file in 2. **Public key on the website.** `pubkey.pem` is a real file in
`~/src/zddc-website/`, deployed to `zddc.varasys.io/pubkey.pem`. `~/src/zddc-website/`, deployed to `zddc.varasys.io/pubkey.pem`.
@ -1544,8 +1543,8 @@ For any path, the resolution order is:
into a directory; the static handler serves it. Beats everything below. into a directory; the static handler serves it. Beats everything below.
2. **Closer-to-leaf `.zddc apps:` entry** — walks `.zddc` files leaf→root 2. **Closer-to-leaf `.zddc apps:` entry** — walks `.zddc` files leaf→root
for an `apps.<app>` entry. The first match wins. Spec is one of: for an `apps.<app>` entry. The first match wins. Spec is one of:
- `stable` (canonical upstream "current stable") - `stable` / `beta` / `alpha` (canonical upstream channel)
- `v0.0.4` (canonical upstream exact-version pin) - `v0.0.4` / `v0.0` / `v0` (canonical upstream version pin)
- `https://...` (full URL to a custom mirror) - `https://...` (full URL to a custom mirror)
- `./local.html` / `/abs/path.html` (local file) - `./local.html` / `/abs/path.html` (local file)
3. **Embedded** — the build-time HTML compiled into the binary. 3. **Embedded** — the build-time HTML compiled into the binary.
@ -1567,7 +1566,7 @@ to the embedded copy and emits a one-time WARN log per source. The
```yaml ```yaml
# <ZDDC_ROOT>/Project-A/.zddc # <ZDDC_ROOT>/Project-A/.zddc
apps: apps:
classifier: v0.0.4 # pin classifier to v0.0.4 for this project classifier: alpha # track alpha for this project
archive: https://my-mirror.internal/zddc/archive_v0.0.4.html # custom mirror, pinned archive: https://my-mirror.internal/zddc/archive_v0.0.4.html # custom mirror, pinned
browse: ./our-browse.html # local fork browse: ./our-browse.html # local fork
``` ```
@ -1757,7 +1756,7 @@ https://codeberg.org/VARASYS/ZDDC/releases/download/zddc-server-vX.Y.Z/zddc-serv
Browse all releases at <https://codeberg.org/VARASYS/ZDDC/releases>. Browse all releases at <https://codeberg.org/VARASYS/ZDDC/releases>.
Each release publishes one canonical "current stable" URL and a set of immutable per-version URLs — no preview channels for binary distribution. Active dev/soak happens via the [`helm/zddc-server-dev/`](../helm/zddc-server-dev/) chart, which builds zddc-server from source on every pod restart against any commit you point it at. The BMC `tnd-zddc-chart` follows the same model — its dev branch can pin to a `<X.Y.Z>-beta-<sha>` snapshot produced by `./build beta`. There is no container image; if you want your own, copy the static binary into a `FROM scratch` or `FROM alpine` base in a few lines, or use one of the helm charts which compile from source via init container. There is no alpha/beta channel for binary distribution. Active dev/soak happens via the [`helm/zddc-server-dev/`](../helm/zddc-server-dev/) chart, which builds zddc-server from source on every pod restart against any commit you point it at. There is no container image; if you want your own, copy the static binary into a `FROM scratch` or `FROM alpine` base in a few lines, or use one of the helm charts which compile from source via init container.
### Env-var contract (for chart consumers) ### Env-var contract (for chart consumers)
@ -1795,12 +1794,13 @@ To run unit tests:
## Release tagging ## Release tagging
zddc-server has no separate release script. The repo's top-level `./build release [version]` is the canonical path: it cross-compiles the four binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the per-platform canonical symlinks, regenerates the per-version + canonical stub pages, refreshes the index, and tags `zddc-server-v<X.Y.Z>` alongside the seven HTML-tool tags. zddc-server has no separate release script. The repo's top-level `./build alpha|beta|release [version]` is the canonical path: it cross-compiles the four binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the lockstep symlink chain (one set per platform), regenerates the per-version + per-channel stub pages, refreshes the index, and (on stable cuts) tags `zddc-server-v<X.Y.Z>` alongside the eight HTML-tool tags.
```sh ```sh
./build release # lockstep stable, coordinated next version ./build release # lockstep stable, coordinated next version
./build release 1.2.0 # lockstep stable, explicit version ./build release 1.2.0 # lockstep stable, explicit version
./build beta # internal SHA snapshot for the BMC dev chart pipeline ./build alpha # lockstep alpha cut
./build beta # lockstep beta cut
./deploy --releases # publish dist/release-output/ to /srv/zddc/releases/ ./deploy --releases # publish dist/release-output/ to /srv/zddc/releases/
``` ```
@ -1815,9 +1815,9 @@ Single-developer / solo-release flow by design — no CI babysitting, no separat
### Versioning ### Versioning
Clean semver, lockstep across all 8 artifacts (7 HTML + zddc-server). Stable cuts get `<tool>-vX.Y.Z` tags for every tool, all 8 sharing the same X.Y.Z. There are no alpha/beta tags — the canonical URL `<tool>.html` is the "always latest stable" URL; counters would defeat that. Active dev runs via `helm/zddc-server-dev/`, which builds from source on each rollout. The BMC chart pipeline pins to a `<X.Y.Z>-beta-<sha>` snapshot (produced by `./build beta`) when it wants to test pre-stable code. Clean semver, lockstep across all nine artifacts (8 HTML + zddc-server). Stable cuts get `<tool>-vX.Y.Z` tags for every tool, all nine sharing the same X.Y.Z. There are no alpha/beta tags — channel URLs are stable URLs by design (counters defeat that). Active dev runs via `helm/zddc-server-dev/`, which builds from source on each rollout.
Historical tags like `zddc-server-v0.0.8-alpha.1` from earlier experiments stay as artifacts; no new alpha/beta tags are created going forward. The two existing `zddc-server-v0.0.8-alpha.1` and `zddc-server-v0.0.8-alpha.2` tags from a previous experiment stay as historical artifacts; no new alpha/beta tags are created going forward.
--- ---

View file

@ -218,12 +218,6 @@ func main() {
"cache_ttl", cfg.OPACacheTTL, "cache_ttl", cfg.OPACacheTTL,
"no_auth", cfg.NoAuth) "no_auth", cfg.NoAuth)
// Bootstrap sanity: warn loudly (but don't fail) when the root .zddc
// grants nobody anything. Embedded defaults.zddc.yaml ships with empty
// role members, so a fresh deployment refuses every request until the
// operator populates the file.
warnIfNoBootstrap(cfg)
// Token store: bearer-token issuance and validation. // Token store: bearer-token issuance and validation.
// Persists under <ZDDC_ROOT>/.zddc.d/tokens/ — already excluded // Persists under <ZDDC_ROOT>/.zddc.d/tokens/ — already excluded
// from public listings (fs.ListDirectory dot-prefix filter) and // from public listings (fs.ListDirectory dot-prefix filter) and
@ -552,46 +546,6 @@ func setupApps(cfg config.Config) (*apps.Server, error) {
return apps.NewServer(cfg.Root, cache, fetcher, version), nil return apps.NewServer(cfg.Root, cache, fetcher, version), nil
} }
// warnIfNoBootstrap fires a startup slog.Warn when the root .zddc grants
// nobody anything — the embedded defaults.zddc.yaml ships with empty role
// members, so a deployment without operator-populated admins / acl
// permissions / role members refuses every request. Skipped under
// --no-auth (auth disabled; warning would be redundant). Per-project
// .zddc files may legitimately carry all grants, so the warning text
// tells the operator they can ignore it in that case.
//
// Master-mode only — the bootstrap concept doesn't apply in client
// (proxy/cache/mirror) mode, where cfg.Root is the cache directory.
func warnIfNoBootstrap(cfg config.Config) {
if cfg.NoAuth {
return
}
rootPath := filepath.Join(cfg.Root, ".zddc")
rootZddc, err := zddc.ParseFile(rootPath)
if err != nil {
slog.Warn("root .zddc not present or unreadable; ZDDC will refuse every request until you create it. "+
"See README.md '## Deploy: bootstrap config' or AGENTS.md '## zddc-server / ### Bootstrap config'.",
"path", rootPath, "err", err)
return
}
hasAdmin := len(rootZddc.Admins) > 0
hasPerm := len(rootZddc.ACL.Permissions) > 0
hasRoleMembers := false
for _, role := range rootZddc.Roles {
if len(role.Members) > 0 {
hasRoleMembers = true
break
}
}
if !hasAdmin && !hasPerm && !hasRoleMembers {
slog.Warn("root .zddc grants nobody anything (no admins, no acl.permissions, no role members). "+
"ZDDC will refuse every request until you populate it. "+
"If you intentionally grant only at per-project levels, you can ignore this. "+
"See README.md '## Deploy: bootstrap config' or AGENTS.md '## zddc-server / ### Bootstrap config'.",
"path", rootPath)
}
}
// printVersions writes the binary version + the build label of every app // printVersions writes the binary version + the build label of every app
// embedded into the binary. Called by --version and reused for the // embedded into the binary. Called by --version and reused for the
// startup log line. // startup log line.
@ -1279,15 +1233,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// see RecognizeVirtualConvert). The .md source serves // see RecognizeVirtualConvert). The .md source serves
// normally here.) // normally here.)
// Record-history list: GET <record>.yaml?history=1 returns the
// list of prior revisions stored under <dir>/.history/<base>/.
// ACL already passed (parent-dir chain). Non-record paths fall
// through to the normal file serve.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Get("history") == "1" {
handler.ServeHistoryList(w, r, absPath)
return
}
handler.ServeFile(w, r, absPath) handler.ServeFile(w, r, absPath)
} }

View file

@ -18,8 +18,8 @@
// //
// Spec forms (each is a string value in `.zddc apps:`): // Spec forms (each is a string value in `.zddc apps:`):
// //
// :stable / :v0.0.4 — channel-only // :stable / :beta / :alpha / :v0.0.4 / :v0.0 / :v0 — channel-only
// stable / v0.0.4 / 0.0.4 — channel-only (no leading colon) // stable / beta / alpha / v0.0.4 / v0.0 / v0 — channel-only (no leading colon)
// https://host/path — URL-prefix only (combines with cascade channel) // https://host/path — URL-prefix only (combines with cascade channel)
// https://host/path:stable — URL-prefix + channel (composes) // https://host/path:stable — URL-prefix + channel (composes)
// https://host/path/file.html — terminal full URL (used as-is) // https://host/path/file.html — terminal full URL (used as-is)
@ -84,7 +84,7 @@ type SpecComponents struct {
// Composable forms — either or both may be set, both may be empty // Composable forms — either or both may be set, both may be empty
// (caller should treat empty-everything as a no-op). // (caller should treat empty-everything as a no-op).
URLPrefix string // "https://host/path" (no trailing /) URLPrefix string // "https://host/path" (no trailing /)
Channel string // "stable" (latest), "v0.0.4" (exact version pin) Channel string // "stable" / "beta" / "alpha" / "v0.0.4" / "v0.0" / "v0"
} }
// IsTerminal reports whether this spec terminates composition. // IsTerminal reports whether this spec terminates composition.
@ -144,7 +144,7 @@ func ParseSpec(spec, zddcDir, root string) (SpecComponents, error) {
// https://host:8080/path:stable → URLPrefix=https://host:8080/path, Channel=stable // https://host:8080/path:stable → URLPrefix=https://host:8080/path, Channel=stable
// https://host:8080/path → URLPrefix=https://host:8080/path // https://host:8080/path → URLPrefix=https://host:8080/path
// https://host/path/file.html → FullURL=https://host/path/file.html (terminal) // https://host/path/file.html → FullURL=https://host/path/file.html (terminal)
// https://host/path/file.html:stable → error (terminal URL with extra suffix) // https://host/path/file.html:beta → error (terminal URL with extra suffix)
func parseURLSpec(spec string) (SpecComponents, error) { func parseURLSpec(spec string) (SpecComponents, error) {
// Locate the channel separator: last `:` that comes after the last `/`. // Locate the channel separator: last `:` that comes after the last `/`.
lastSlash := strings.LastIndex(spec, "/") lastSlash := strings.LastIndex(spec, "/")
@ -191,12 +191,10 @@ func parseURLSpec(spec string) (SpecComponents, error) {
return out, nil return out, nil
} }
// isValidChannelOrVersion reports whether s is `stable` (the canonical // isValidChannelOrVersion reports whether s is `stable`/`beta`/`alpha` or a
// "current stable" alias) or an exact-version pin like `v0.0.4` / `0.0.4`. // version like `v0.0.4`/`0.0.4`/`v0.0`/`v0`.
// Partial pins (`v0.0`, `v0`) and the legacy `beta`/`alpha` channels
// are no longer accepted — the upstream publishes only stable + exact.
func isValidChannelOrVersion(s string) bool { func isValidChannelOrVersion(s string) bool {
if s == "stable" { if s == "stable" || s == "beta" || s == "alpha" {
return true return true
} }
rest := strings.TrimPrefix(s, "v") rest := strings.TrimPrefix(s, "v")
@ -204,7 +202,7 @@ func isValidChannelOrVersion(s string) bool {
return false return false
} }
parts := strings.Split(rest, ".") parts := strings.Split(rest, ".")
if len(parts) != 3 { if len(parts) > 3 {
return false return false
} }
for _, p := range parts { for _, p := range parts {
@ -223,7 +221,7 @@ func isValidChannelOrVersion(s string) bool {
// normalizeChannel ensures versions carry the `v` prefix (so the resulting // normalizeChannel ensures versions carry the `v` prefix (so the resulting
// filename is `<app>_v<X.Y.Z>.html` per upstream convention). // filename is `<app>_v<X.Y.Z>.html` per upstream convention).
func normalizeChannel(s string) string { func normalizeChannel(s string) string {
if s == "stable" { if s == "stable" || s == "beta" || s == "alpha" {
return s return s
} }
if !strings.HasPrefix(s, "v") { if !strings.HasPrefix(s, "v") {
@ -361,18 +359,9 @@ func (s *appsState) finalize() (Source, bool, error) {
if channel == "" { if channel == "" {
channel = DefaultChannel channel = DefaultChannel
} }
// channel == "stable" → canonical URL <prefix>/<app>.html (a
// symlink that always follows the latest stable cut).
// channel == "v<X.Y.Z>" → immutable per-version URL.
var name string
if channel == "stable" {
name = s.app + ".html"
} else {
name = s.app + "_" + channel + ".html"
}
return Source{ return Source{
App: s.app, App: s.app,
URL: urlPrefix + "/" + name, URL: urlPrefix + "/" + s.app + "_" + channel + ".html",
}, true, nil }, true, nil
} }

View file

@ -11,13 +11,15 @@ import (
// ── ParseSpec ──────────────────────────────────────────────────────────── // ── ParseSpec ────────────────────────────────────────────────────────────
func TestParseSpec_Channels(t *testing.T) { func TestParseSpec_Channels(t *testing.T) {
// "stable" is the only channel alias (latest stable). beta and alpha
// channels no longer exist as public concepts.
cases := []struct { cases := []struct {
spec, wantChan string spec, wantChan string
}{ }{
{"stable", "stable"}, {"stable", "stable"},
{"beta", "beta"},
{"alpha", "alpha"},
{":stable", "stable"}, {":stable", "stable"},
{":beta", "beta"},
{":alpha", "alpha"},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) { t.Run(tc.spec, func(t *testing.T) {
@ -36,17 +38,18 @@ func TestParseSpec_Channels(t *testing.T) {
} }
func TestParseSpec_Versions(t *testing.T) { func TestParseSpec_Versions(t *testing.T) {
// Exact-version pins only. Partial pins (v0.0, v0) no longer exist
// — the upstream publishes <tool>.html (current stable) and
// <tool>_v<X.Y.Z>.html (exact-version immutable). Bare "0.0.4"
// (no v prefix) is normalized to "v0.0.4".
cases := []struct { cases := []struct {
spec, wantChan string spec, wantChan string
}{ }{
{"v0.0.4", "v0.0.4"}, {"v0.0.4", "v0.0.4"},
{"0.0.4", "v0.0.4"}, {"0.0.4", "v0.0.4"},
{"v0.0", "v0.0"},
{"0.0", "v0.0"},
{"v0", "v0"},
{"0", "v0"},
{":v0.0.4", "v0.0.4"}, {":v0.0.4", "v0.0.4"},
{":0.0.4", "v0.0.4"}, {":0.0.4", "v0.0.4"},
{":v0", "v0"},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) { t.Run(tc.spec, func(t *testing.T) {
@ -61,19 +64,6 @@ func TestParseSpec_Versions(t *testing.T) {
} }
} }
func TestParseSpec_RejectsLegacyChannelsAndPartialPins(t *testing.T) {
// alpha/beta channels and partial-version pins are no longer valid.
rejected := []string{"alpha", "beta", ":alpha", ":beta", "v0.0", "v0", "0.0", "0", ":v0.0"}
for _, spec := range rejected {
t.Run(spec, func(t *testing.T) {
_, err := ParseSpec(spec, "/root", "/root")
if err == nil {
t.Errorf("expected error for %q, got none", spec)
}
})
}
}
func TestParseSpec_URLPrefix(t *testing.T) { func TestParseSpec_URLPrefix(t *testing.T) {
cases := []struct { cases := []struct {
spec, wantPrefix, wantChan string spec, wantPrefix, wantChan string
@ -81,13 +71,14 @@ func TestParseSpec_URLPrefix(t *testing.T) {
{"https://my-mirror.example/releases", "https://my-mirror.example/releases", ""}, {"https://my-mirror.example/releases", "https://my-mirror.example/releases", ""},
{"https://my-mirror.example/releases/", "https://my-mirror.example/releases", ""}, // trailing slash stripped {"https://my-mirror.example/releases/", "https://my-mirror.example/releases", ""}, // trailing slash stripped
{"https://my-mirror.example/releases:stable", "https://my-mirror.example/releases", "stable"}, {"https://my-mirror.example/releases:stable", "https://my-mirror.example/releases", "stable"},
{"https://my-mirror.example/releases:beta", "https://my-mirror.example/releases", "beta"},
{"https://my-mirror.example/releases:v0.0.4", "https://my-mirror.example/releases", "v0.0.4"}, {"https://my-mirror.example/releases:v0.0.4", "https://my-mirror.example/releases", "v0.0.4"},
// Port colon must NOT be confused with channel separator. // Port colon must NOT be confused with channel separator.
{"https://my-mirror.example:8080/releases", "https://my-mirror.example:8080/releases", ""}, {"https://my-mirror.example:8080/releases", "https://my-mirror.example:8080/releases", ""},
{"https://my-mirror.example:8080/releases:stable", "https://my-mirror.example:8080/releases", "stable"}, {"https://my-mirror.example:8080/releases:stable", "https://my-mirror.example:8080/releases", "stable"},
// Colon embedded in path before final slash — treated as part of path. // Colon embedded in path before final slash — treated as part of path.
{"https://host/some:thing/releases", "https://host/some:thing/releases", ""}, {"https://host/some:thing/releases", "https://host/some:thing/releases", ""},
{"https://host/some:thing/releases:v0.0.4", "https://host/some:thing/releases", "v0.0.4"}, {"https://host/some:thing/releases:beta", "https://host/some:thing/releases", "beta"},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) { t.Run(tc.spec, func(t *testing.T) {
@ -201,15 +192,13 @@ func TestResolve_NoEntries(t *testing.T) {
func TestResolve_PerAppChannelOnly(t *testing.T) { func TestResolve_PerAppChannelOnly(t *testing.T) {
root := t.TempDir() root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{ chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": "stable"}, Apps: map[string]string{"archive": "beta"},
}}} }}}
src, has, err := Resolve(chain, "archive", root, root) src, has, err := Resolve(chain, "archive", root, root)
if err != nil || !has { if err != nil || !has {
t.Fatalf("has=%v err=%v", has, err) t.Fatalf("has=%v err=%v", has, err)
} }
// stable channel → canonical URL (no _stable_ suffix); the upstream want := DefaultUpstreamReleases + "/archive_beta.html"
// publishes a symlink at this URL pointing at the latest version.
want := DefaultUpstreamReleases + "/archive.html"
if src.URL != want { if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want) t.Errorf("got URL=%q, want %q", src.URL, want)
} }
@ -234,40 +223,40 @@ func TestResolve_DefaultProvidesURLAndChannel(t *testing.T) {
root := t.TempDir() root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{ chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{ Apps: map[string]string{
"default": "https://mirror.example/releases:v0.0.4", "default": "https://mirror.example/releases:beta",
}, },
}}} }}}
src, has, err := Resolve(chain, "archive", root, root) src, has, err := Resolve(chain, "archive", root, root)
if err != nil || !has { if err != nil || !has {
t.Fatalf("has=%v err=%v", has, err) t.Fatalf("has=%v err=%v", has, err)
} }
if src.URL != "https://mirror.example/releases/archive_v0.0.4.html" { if src.URL != "https://mirror.example/releases/archive_beta.html" {
t.Errorf("got URL=%q", src.URL) t.Errorf("got URL=%q", src.URL)
} }
} }
func TestResolve_DefaultPlusPerAppChannelOverride(t *testing.T) { func TestResolve_DefaultPlusPerAppChannelOverride(t *testing.T) {
// default=https://zddc.varasys.io/releases:stable, classifier=:v0.0.4 // User's example: default=https://zddc.varasys.io/releases:stable,
// → classifier pinned to v0.0.4 on the same mirror. // classifier=:beta → mirror URL with classifier_beta.html.
root := t.TempDir() root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{ chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{ Apps: map[string]string{
"default": "https://zddc.varasys.io/releases:stable", "default": "https://zddc.varasys.io/releases:stable",
"classifier": ":v0.0.4", "classifier": ":beta",
}, },
}}} }}}
src, _, err := Resolve(chain, "classifier", root, root) src, _, err := Resolve(chain, "classifier", root, root)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if src.URL != "https://zddc.varasys.io/releases/classifier_v0.0.4.html" { if src.URL != "https://zddc.varasys.io/releases/classifier_beta.html" {
t.Errorf("got URL=%q", src.URL) t.Errorf("got URL=%q", src.URL)
} }
} }
func TestResolve_DefaultPlusPerAppURLPrefixOverride(t *testing.T) { func TestResolve_DefaultPlusPerAppURLPrefixOverride(t *testing.T) {
// default=...:stable, archive=https://my.local.stuff/releases // User's example: default=...:stable, archive=https://my.local.stuff/releases
// → custom URL + default channel (stable, canonical filename). // → custom URL + default channel (stable).
root := t.TempDir() root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{ chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{ Apps: map[string]string{
@ -279,7 +268,7 @@ func TestResolve_DefaultPlusPerAppURLPrefixOverride(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if src.URL != "https://my.local.stuff/releases/archive.html" { if src.URL != "https://my.local.stuff/releases/archive_stable.html" {
t.Errorf("got URL=%q", src.URL) t.Errorf("got URL=%q", src.URL)
} }
} }
@ -289,13 +278,13 @@ func TestResolve_DeeperLevelOverridesParentChannel(t *testing.T) {
requestDir := filepath.Join(root, "Project-A") requestDir := filepath.Join(root, "Project-A")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{ chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{Apps: map[string]string{"default": ":stable"}}, {Apps: map[string]string{"default": ":stable"}},
{Apps: map[string]string{"default": ":v0.0.4"}}, {Apps: map[string]string{"default": ":beta"}},
}} }}
src, _, err := Resolve(chain, "archive", root, requestDir) src, _, err := Resolve(chain, "archive", root, requestDir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
want := DefaultUpstreamReleases + "/archive_v0.0.4.html" want := DefaultUpstreamReleases + "/archive_beta.html"
if src.URL != want { if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want) t.Errorf("got URL=%q, want %q", src.URL, want)
} }
@ -312,9 +301,8 @@ func TestResolve_DeeperLevelOverridesParentURL(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// b.example URL prefix wins; channel inherited (stable → canonical // b.example URL prefix wins; channel inherited (stable).
// filename, no _stable_ suffix). want := "https://b.example/releases/archive_stable.html"
want := "https://b.example/releases/archive.html"
if src.URL != want { if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want) t.Errorf("got URL=%q, want %q", src.URL, want)
} }
@ -341,13 +329,13 @@ func TestResolve_DeeperNonTerminalOverridesParentTerminal(t *testing.T) {
requestDir := filepath.Join(root, "Project-A") requestDir := filepath.Join(root, "Project-A")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{ chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{Apps: map[string]string{"archive": "https://a.example/archive.html"}}, // terminal {Apps: map[string]string{"archive": "https://a.example/archive.html"}}, // terminal
{Apps: map[string]string{"archive": "v0.0.4"}}, // non-terminal {Apps: map[string]string{"archive": "alpha"}}, // non-terminal
}} }}
src, _, err := Resolve(chain, "archive", root, requestDir) src, _, err := Resolve(chain, "archive", root, requestDir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
want := DefaultUpstreamReleases + "/archive_v0.0.4.html" want := DefaultUpstreamReleases + "/archive_alpha.html"
if src.URL != want { if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want) t.Errorf("got URL=%q, want %q", src.URL, want)
} }
@ -394,7 +382,7 @@ func TestResolve_PerAppOverridesDefaultAtSameLevel(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if src2.URL != "https://a.example/releases/classifier.html" { if src2.URL != "https://a.example/releases/classifier_stable.html" {
t.Errorf("got classifier URL=%q (want a.example default)", src2.URL) t.Errorf("got classifier URL=%q (want a.example default)", src2.URL)
} }
} }
@ -429,9 +417,9 @@ func TestPreviewLine(t *testing.T) {
} }
}) })
t.Run("default channel → URL", func(t *testing.T) { t.Run("default channel → URL", func(t *testing.T) {
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{Apps: map[string]string{"default": ":v0.0.4"}}}} chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{Apps: map[string]string{"default": ":beta"}}}}
got := PreviewLine(chain, "archive", root, root) got := PreviewLine(chain, "archive", root, root)
if !strings.Contains(got, "archive_v0.0.4.html") { if !strings.Contains(got, "archive_beta.html") {
t.Errorf("got %q", got) t.Errorf("got %q", got)
} }
}) })

View file

@ -269,7 +269,7 @@ a:hover {
} }
/* Subdued / de-emphasized variant. /* Subdued / de-emphasized variant.
Used on the "Use Local Directory" button when a tool is operating Used on the "Add Local Directory" button when a tool is operating
in server (online) mode — the local-dir affordance is still in server (online) mode — the local-dir affordance is still
available but visually quieter, since the typical user already available but visually quieter, since the typical user already
has the directory loaded from the server. */ has the directory loaded from the server. */
@ -331,11 +331,6 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
/* Let the left / right groups wrap to a second row at narrow
viewports rather than overflowing the viewport edge. row-gap
gives a small breathing strip when wrapped. */
flex-wrap: wrap;
row-gap: 0.3rem;
} }
/* Left and right groups inside .app-header. Both flex-row so their /* Left and right groups inside .app-header. Both flex-row so their
@ -347,35 +342,16 @@ a:hover {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
/* Allow the title to shrink (and ellipsize) before the action
buttons get pushed off-screen at narrow viewports. */
min-width: 0;
flex-wrap: wrap;
row-gap: 0.3rem;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0;
}
/* Title group (title + build label). Made shrinkable so narrow
viewports don't push the action buttons out of view; the title
itself ellipsizes via the rule below. */
.header-title-group {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
flex-shrink: 1;
} }
/* Tool name inside the header. Renders in the display serif so the /* Tool name inside the header. Renders in the display serif so the
tool's identity reads as a document title, not a UI label. tool's identity reads as a document title, not a UI label. */
overflow + ellipsis on min-width:0 lets the title compress
gracefully when there's no room. */
.app-header__title { .app-header__title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 18px; font-size: 18px;
@ -383,9 +359,6 @@ a:hover {
color: var(--text); color: var(--text);
letter-spacing: 0; letter-spacing: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
/* Brand logo — sits left of the title in every tool's app-header. /* Brand logo — sits left of the title in every tool's app-header.
@ -836,127 +809,61 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
/* shared/elevation.css — admin-elevation toggle in the tool header. /* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
Renders only for users with admin scope (handled by elevation.js; Sits as a sibling immediately under .app-header (mounted by JS).
the placeholder is `.hidden` by default). When visible, sits left Rendered only in online mode when a project segment is in the URL. */
of the theme button — sudo-style affordance for opting into admin
powers. */
.elevation-toggle { .zddc-stage-strip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--text-muted);
user-select: none;
cursor: pointer;
padding: 0.15rem 0.45rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.elevation-toggle:hover {
background: var(--bg-hover);
border-color: var(--border-dark);
}
.elevation-toggle input[type="checkbox"] {
margin: 0;
cursor: pointer;
accent-color: var(--danger);
}
.elevation-toggle__label {
cursor: pointer;
letter-spacing: 0.02em;
}
/* Active state — when elevation is ON, the toggle reads as "armed"
so the user can't miss that admin powers are currently live.
:has(:checked) lets us style the wrapper based on the inner
checkbox without JS. */
.elevation-toggle:has(input:checked) {
background: rgba(220, 53, 69, 0.12);
border-color: var(--danger);
color: var(--danger);
font-weight: 600;
}
/* Page-wide chrome when admin mode is active. The toggle alone is
easy to miss; these add an inescapable visual cue:
1. Thin red border around the entire viewport — peripheral-
vision reminder regardless of which tool / scroll position.
2. Sticky banner across the top with a one-click "Drop admin"
button so the user can disarm without hunting for the toggle.
Both rendered ONLY when the zddc-elevate cookie is set; the
shared/elevation.js init() syncs the body class on every page
load and tears it down when elevation is cleared.
Frame uses fixed positioning + pointer-events:none so it doesn't
reflow content or steal clicks. An inset outline on <body> was
tried first but overdrew content in tools whose root layout butts
right up to the viewport edge (browse split-pane, archive grid). */
body.is-elevated::after {
content: "";
position: fixed;
inset: 0;
border: 3px solid var(--danger, #dc3545);
pointer-events: none;
z-index: 9200; /* above banner (9100) so the frame paints on top */
}
.elevation-banner {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.5rem;
padding: 0.4rem 0.9rem; padding: 0.3rem 1rem;
background: rgba(220, 53, 69, 0.95); background: var(--bg);
color: #fff; border-bottom: 1px solid var(--border);
font-size: 0.85rem; font-size: 0.8rem;
font-weight: 500; line-height: 1.3;
letter-spacing: 0.01em;
position: sticky;
top: 0;
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
.elevation-banner__dot {
width: 0.5rem;
height: 0.5rem;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
animation: elev-pulse 1.6s infinite;
flex-shrink: 0; flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
} }
@keyframes elev-pulse { .zddc-stage-strip__project {
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); } color: var(--text);
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
}
.elevation-banner__msg {
flex: 1 1 auto;
}
.elevation-banner__off {
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.7);
color: #fff;
padding: 0.18rem 0.65rem;
border-radius: var(--radius, 4px);
font-size: 0.78rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; margin-right: 0.15rem;
cursor: pointer;
flex-shrink: 0;
} }
.elevation-banner__off:hover {
background: rgba(255, 255, 255, 0.3); .zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
} }
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor /* shared/logo.css — paired with shared/logo.js. The wrapping anchor
@ -2563,18 +2470,12 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp">v0.0.18</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>
</div> </div>
<div class="header-right"> <div class="header-right">
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button> <button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help">?</button> <button id="help-btn" class="btn btn-secondary" title="Help">?</button>
</div> </div>
@ -2779,7 +2680,7 @@ td[data-field="trackingNumber"] {
<div id="noDirectoryMessage" class="empty-state empty-state--overlay"> <div id="noDirectoryMessage" class="empty-state empty-state--overlay">
<div class="empty-state__inner empty-state__inner--centered"> <div class="empty-state__inner empty-state__inner--centered">
<h2>Welcome to ZDDC Archive</h2> <h2>Welcome to ZDDC Archive</h2>
<p>Click <strong>Use Local Directory</strong> to select an archive folder to browse.</p> <p>Click <strong>Add Local Directory</strong> to select an archive folder to browse.</p>
<p>This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.</p> <p>This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.</p>
<p><strong>How to navigate:</strong></p> <p><strong>How to navigate:</strong></p>
<ul class="welcome-list"> <ul class="welcome-list">
@ -2824,7 +2725,7 @@ td[data-field="trackingNumber"] {
<h3>Getting Started</h3> <h3>Getting Started</h3>
<ol> <ol>
<li>When opened from a web server, the archive loads automatically from that server.</li> <li>When opened from a web server, the archive loads automatically from that server.</li>
<li>Click <strong>Use Local Directory</strong> to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.</li> <li>Click <strong>Add Local Directory</strong> to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.</li>
<li>The browser scans for grouping folders and transmittal folders automatically.</li> <li>The browser scans for grouping folders and transmittal folders automatically.</li>
<li>Select folders in the left panel to see their files in the main table.</li> <li>Select folders in the left panel to see their files in the main table.</li>
</ol> </ol>
@ -4147,7 +4048,6 @@ X.B(E,Y);return E}return J}())
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
'REC', 'REC',
'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
'TBD',
]; ];
var STATUS_SET = {}; var STATUS_SET = {};
@ -5017,6 +4917,211 @@ X.B(E,Y);return E}return J}())
} }
})(); })();
// shared/nav.js — lateral navigation strip across the project's
// cascade-declared stages. Mounted as a sibling of <header class="app-
// header"> on DOMContentLoaded, hydrated from the project root's
// directory listing.
//
// Stage discovery is cascade-driven (Phase 4c): fetch the project
// root's JSON listing, filter to entries with `declared: true`
// (server stamps these from the .zddc cascade's paths: tree), and
// render in canonical workflow order with display_name overrides
// honored. An operator who edits the project's .zddc paths: to add
// a new declared child sees it in the strip; one who removes a
// canonical entry sees the strip drop it.
//
// When the fetch fails (offline / no-server / file://), the strip
// falls back to the hardcoded four-stage list so existing
// deployments don't lose chrome. Hardcoded labels in this file are
// the LAST resort — the cascade is the source of truth in normal
// operation.
//
// Stage URLs follow the slash/no-slash convention: no slash opens
// the stage's default tool. Operators on non-standard layouts can
// override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
// Hardcoded fallback for offline / file:// / fetch-error contexts.
// Server-driven discovery (FETCH_STAGES below) is the normal path.
var FALLBACK_STAGES = [
{ name: 'archive', label: 'Archive' },
{ name: 'working', label: 'Working' },
{ name: 'staging', label: 'Staging' },
{ name: 'reviewing', label: 'Reviewing' },
];
// Canonical workflow order. Stages appearing in this list are
// rendered in this order; any extras the cascade declares are
// appended alphabetically.
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
for (var i = 0; i < stages.length; i++) {
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
return stages[i].name;
}
}
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function titleCase(s) {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function sortByWorkflow(stages) {
return stages.slice().sort(function (a, b) {
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
if (ia >= 0 && ib >= 0) return ia - ib;
if (ia >= 0) return -1;
if (ib >= 0) return 1;
return a.name.localeCompare(b.name);
});
}
// Fetch the project root listing and extract declared stage
// entries. Returns [] on any error so callers fall back to the
// hardcoded list. Each stage entry is {name, label} — label
// honors the cascade's display: override when present.
async function fetchStagesFor(project) {
try {
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) return [];
var data = await resp.json();
if (!Array.isArray(data)) return [];
var stages = [];
for (var i = 0; i < data.length; i++) {
var e = data[i];
if (!e || !e.declared || !e.is_dir) continue;
var bare = (e.name || '').replace(/\/$/, '');
if (!bare) continue;
stages.push({
name: bare,
label: e.display_name || titleCase(bare),
});
}
return sortByWorkflow(stages);
} catch (_e) {
return [];
}
}
function buildStrip(project, active, stages) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < stages.length; i++) {
var s = stages[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return; // already mounted
}
var active = currentStage(location.pathname, stages);
var strip = buildStrip(project, active, stages);
header.parentNode.insertBefore(strip, header);
}
async function mount() {
if (!shouldRender()) return;
var project = projectSegment(location.pathname);
if (!project) return;
// Render the hardcoded fallback immediately so the strip
// appears with no flicker, then upgrade to cascade-resolved
// stages once the fetch completes.
mountWith(project, FALLBACK_STAGES);
var fetched = await fetchStagesFor(project);
if (fetched.length === 0) return; // fetch failed → keep fallback
// Replace the strip with the cascade-driven one. Remove the
// existing strip first so mountWith re-mounts cleanly.
var existing = document.querySelector('.zddc-stage-strip');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
mountWith(project, fetched);
}
window.zddc.nav = {
mount: mount,
_projectSegment: projectSegment,
_currentStage: currentStage,
_fallbackStages: FALLBACK_STAGES,
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
// shared/logo.js — turn the inert <svg class="app-header__logo"> on // shared/logo.js — turn the inert <svg class="app-header__logo"> on
// every tool's header into a clickable link. The destination is the // every tool's header into a clickable link. The destination is the
// nearest "home" the user can sensibly back out to: // nearest "home" the user can sensibly back out to:
@ -9730,7 +9835,7 @@ window.app.modules.filtering = {
// Apply UI differences based on source mode // Apply UI differences based on source mode
function applySourceModeUI() { function applySourceModeUI() {
// "Use Local Directory" button is always visible in both modes — // "Add Local Directory" button is always visible in both modes —
// in HTTP mode the user can augment the online archive with local directories. // in HTTP mode the user can augment the online archive with local directories.
} }
@ -10710,155 +10815,6 @@ window.app.modules.filtering = {
} }
}()); }());
// shared/elevation.js — admin elevation toggle.
//
// Sudo-style model: admins behave as normal users by default; clicking
// the header toggle elevates the session so admin escape hatches (WORM
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
// State is carried in a `zddc-elevate=1` cookie that the server reads
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
// Only renders the toggle when /.profile/access reports the caller has
// some admin scope — a non-admin sees nothing, which keeps the chrome
// quiet for the common case. The toggle fades in once access loads so
// non-admins never even see the affordance flash.
//
// Click flow: set/clear the cookie, then reload the page so the server
// sees the new state on the next render. The reload is intentional —
// admin scaffolds in tool HTML are server-rendered for some tools, so
// a soft state flip on the client alone wouldn't reach those.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.elevation) return;
var COOKIE_NAME = 'zddc-elevate';
function isElevated() {
var parts = document.cookie.split(';');
for (var i = 0; i < parts.length; i++) {
var kv = parts[i].trim().split('=');
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
}
return false;
}
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
// shapes. Max-Age caps the elevation window so a forgotten
// tab doesn't leave admin powers active indefinitely (sudo's
// 5-minute precedent informs the number — 30 minutes is a
// reasonable trade between annoyance and exposure).
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
} else {
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
}
}
async function fetchAccess() {
try {
var resp = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
function render(host, elevated) {
host.classList.remove('hidden');
host.innerHTML =
'<input type="checkbox" id="elevation-checkbox"'
+ (elevated ? ' checked' : '') + '>'
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
+ 'Admin</label>';
var cb = host.querySelector('#elevation-checkbox');
cb.addEventListener('change', function () {
setElevated(cb.checked);
// Hard reload so server-rendered admin surfaces (profile
// page scaffolds, hidden-entry listings) catch up. URL
// and scroll state are preserved by the browser's normal
// back-forward cache rules.
window.location.reload();
});
}
// Page-wide affordances when elevation is active. The toggle alone
// is easy to miss — admin mode silently bypasses WORM and ACL
// restrictions, which produces surprising "I shouldn't have been
// able to do that" moments. A body class + a sticky banner with a
// one-click disable make the armed state unmistakable.
function applyArmedChrome(elevated) {
var b = document.body;
if (!b) return;
if (elevated) b.classList.add('is-elevated');
else b.classList.remove('is-elevated');
var banner = document.getElementById('elevation-banner');
if (elevated) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'elevation-banner';
banner.className = 'elevation-banner';
banner.setAttribute('role', 'alert');
banner.innerHTML =
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
+ '<span class="elevation-banner__msg">'
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
+ '</span>'
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
+ 'Drop admin'
+ '</button>';
document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off');
if (off) off.addEventListener('click', function () {
setElevated(false);
window.location.reload();
});
}
} else if (banner) {
banner.parentNode.removeChild(banner);
}
}
async function init() {
// Body chrome applies on every page load whether or not the
// header has a toggle slot — the banner needs to surface in
// tools / pages that don't host the toggle (e.g. iframed
// classifier inside browse's grid mode), so the user can't
// accidentally write through an elevated context elsewhere.
applyArmedChrome(isElevated());
var host = document.getElementById('elevation-toggle');
if (!host) return; // tool doesn't include the slot yet — no-op
var access = await fetchAccess();
if (!access) return; // anonymous / endpoint missing — no-op
// Surface ONLY for users who have admin authority somewhere.
// /.profile/access ships `can_elevate` as an elevation-
// INDEPENDENT signal — true for any user named in any admin
// list, regardless of current cookie state. The other flags
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
// authority and would be false for an un-elevated admin
// who hasn't toggled yet — so we can't gate on those.
if (!access.can_elevate) return;
render(host, isElevated());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})();
</script> </script>
</body> </body>
</html> </html>

File diff suppressed because one or more lines are too long

View file

@ -269,7 +269,7 @@ a:hover {
} }
/* Subdued / de-emphasized variant. /* Subdued / de-emphasized variant.
Used on the "Use Local Directory" button when a tool is operating Used on the "Add Local Directory" button when a tool is operating
in server (online) mode — the local-dir affordance is still in server (online) mode — the local-dir affordance is still
available but visually quieter, since the typical user already available but visually quieter, since the typical user already
has the directory loaded from the server. */ has the directory loaded from the server. */
@ -331,11 +331,6 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
/* Let the left / right groups wrap to a second row at narrow
viewports rather than overflowing the viewport edge. row-gap
gives a small breathing strip when wrapped. */
flex-wrap: wrap;
row-gap: 0.3rem;
} }
/* Left and right groups inside .app-header. Both flex-row so their /* Left and right groups inside .app-header. Both flex-row so their
@ -347,35 +342,16 @@ a:hover {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
/* Allow the title to shrink (and ellipsize) before the action
buttons get pushed off-screen at narrow viewports. */
min-width: 0;
flex-wrap: wrap;
row-gap: 0.3rem;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0;
}
/* Title group (title + build label). Made shrinkable so narrow
viewports don't push the action buttons out of view; the title
itself ellipsizes via the rule below. */
.header-title-group {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
flex-shrink: 1;
} }
/* Tool name inside the header. Renders in the display serif so the /* Tool name inside the header. Renders in the display serif so the
tool's identity reads as a document title, not a UI label. tool's identity reads as a document title, not a UI label. */
overflow + ellipsis on min-width:0 lets the title compress
gracefully when there's no room. */
.app-header__title { .app-header__title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 18px; font-size: 18px;
@ -383,9 +359,6 @@ a:hover {
color: var(--text); color: var(--text);
letter-spacing: 0; letter-spacing: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
/* Brand logo — sits left of the title in every tool's app-header. /* Brand logo — sits left of the title in every tool's app-header.
@ -836,127 +809,61 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
/* shared/elevation.css — admin-elevation toggle in the tool header. /* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
Renders only for users with admin scope (handled by elevation.js; Sits as a sibling immediately under .app-header (mounted by JS).
the placeholder is `.hidden` by default). When visible, sits left Rendered only in online mode when a project segment is in the URL. */
of the theme button — sudo-style affordance for opting into admin
powers. */
.elevation-toggle { .zddc-stage-strip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--text-muted);
user-select: none;
cursor: pointer;
padding: 0.15rem 0.45rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.elevation-toggle:hover {
background: var(--bg-hover);
border-color: var(--border-dark);
}
.elevation-toggle input[type="checkbox"] {
margin: 0;
cursor: pointer;
accent-color: var(--danger);
}
.elevation-toggle__label {
cursor: pointer;
letter-spacing: 0.02em;
}
/* Active state — when elevation is ON, the toggle reads as "armed"
so the user can't miss that admin powers are currently live.
:has(:checked) lets us style the wrapper based on the inner
checkbox without JS. */
.elevation-toggle:has(input:checked) {
background: rgba(220, 53, 69, 0.12);
border-color: var(--danger);
color: var(--danger);
font-weight: 600;
}
/* Page-wide chrome when admin mode is active. The toggle alone is
easy to miss; these add an inescapable visual cue:
1. Thin red border around the entire viewport — peripheral-
vision reminder regardless of which tool / scroll position.
2. Sticky banner across the top with a one-click "Drop admin"
button so the user can disarm without hunting for the toggle.
Both rendered ONLY when the zddc-elevate cookie is set; the
shared/elevation.js init() syncs the body class on every page
load and tears it down when elevation is cleared.
Frame uses fixed positioning + pointer-events:none so it doesn't
reflow content or steal clicks. An inset outline on <body> was
tried first but overdrew content in tools whose root layout butts
right up to the viewport edge (browse split-pane, archive grid). */
body.is-elevated::after {
content: "";
position: fixed;
inset: 0;
border: 3px solid var(--danger, #dc3545);
pointer-events: none;
z-index: 9200; /* above banner (9100) so the frame paints on top */
}
.elevation-banner {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.5rem;
padding: 0.4rem 0.9rem; padding: 0.3rem 1rem;
background: rgba(220, 53, 69, 0.95); background: var(--bg);
color: #fff; border-bottom: 1px solid var(--border);
font-size: 0.85rem; font-size: 0.8rem;
font-weight: 500; line-height: 1.3;
letter-spacing: 0.01em;
position: sticky;
top: 0;
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
.elevation-banner__dot {
width: 0.5rem;
height: 0.5rem;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
animation: elev-pulse 1.6s infinite;
flex-shrink: 0; flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
} }
@keyframes elev-pulse { .zddc-stage-strip__project {
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); } color: var(--text);
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
}
.elevation-banner__msg {
flex: 1 1 auto;
}
.elevation-banner__off {
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.7);
color: #fff;
padding: 0.18rem 0.65rem;
border-radius: var(--radius, 4px);
font-size: 0.78rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; margin-right: 0.15rem;
cursor: pointer;
flex-shrink: 0;
} }
.elevation-banner__off:hover {
background: rgba(255, 255, 255, 0.3); .zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
} }
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor /* shared/logo.css — paired with shared/logo.js. The wrapping anchor
@ -1774,18 +1681,12 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp">v0.0.18</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
</div> </div>
<div class="header-right"> <div class="header-right">
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button> <button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help">?</button> <button id="help-btn" class="btn btn-secondary" title="Help">?</button>
</div> </div>
@ -1908,7 +1809,7 @@ body.is-elevated::after {
<li>Rename one file or all modified files at once</li> <li>Rename one file or all modified files at once</li>
</ul> </ul>
<p>Click <strong>Use Local Directory</strong> to begin.</p> <p>Click <strong>Add Local Directory</strong> to begin.</p>
<p class="note">This application works entirely in your browser. No data is transmitted to any server.</p> <p class="note">This application works entirely in your browser. No data is transmitted to any server.</p>
</div> </div>
@ -1927,7 +1828,7 @@ body.is-elevated::after {
<h3>Getting Started</h3> <h3>Getting Started</h3>
<ol> <ol>
<li>Click <strong>Use Local Directory</strong> to open a folder containing files to rename.</li> <li>Click <strong>Add Local Directory</strong> to open a folder containing files to rename.</li>
<li>The folder tree on the left shows all sub-folders. Click a folder to load its files.</li> <li>The folder tree on the left shows all sub-folders. Click a folder to load its files.</li>
<li>Edit cells in the spreadsheet to set the new filename components.</li> <li>Edit cells in the spreadsheet to set the new filename components.</li>
<li>Click <strong>Save All</strong> (or save individual rows) to rename the files on disk.</li> <li>Click <strong>Save All</strong> (or save individual rows) to rename the files on disk.</li>
@ -3245,7 +3146,6 @@ X.B(E,Y);return E}return J}())
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
'REC', 'REC',
'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
'TBD',
]; ];
var STATUS_SET = {}; var STATUS_SET = {};
@ -3974,34 +3874,14 @@ X.B(E,Y);return E}return J}())
// Top-level helpers // Top-level helpers
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Resolve "the directory the tool was opened in" for the current // Strip a trailing tool .html (e.g. classifier.html) from a path
// page URL. Two URL shapes serve a tool: // to land on the "directory the tool was opened in".
//
// /…/<tool>.html — file URL; strip the trailing filename.
// /…/<dir>/ — trailing-slash directory URL; keep it.
// /…/<dir> — bare-directory URL served by the
// cascade's `default_tool` (e.g.
// archive/<party>/mdl serves the tables
// tool). Treat as the directory itself
// and append the missing slash.
//
// Discrimination is "does the last segment contain a dot?" — a dot
// is a reliable proxy for "looks like a file with an extension"
// since neither directory names nor default_tool paths contain
// them in this system.
function pathToDir(pathname) { function pathToDir(pathname) {
if (!pathname) return '/'; if (!pathname) return '/';
if (pathname.endsWith('/')) return pathname; if (pathname.endsWith('/')) return pathname;
var slash = pathname.lastIndexOf('/'); var slash = pathname.lastIndexOf('/');
var lastSeg = slash >= 0 ? pathname.substring(slash + 1) : pathname;
if (lastSeg.indexOf('.') !== -1) {
// Has an extension → looks like a file URL → strip the
// filename to land on the parent directory.
return slash >= 0 ? pathname.substring(0, slash + 1) : '/'; return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
} }
// No extension → the URL IS the directory; just close it.
return pathname + '/';
}
// Probe the server-mode root for the current page. Returns: // Probe the server-mode root for the current page. Returns:
// //
@ -4080,14 +3960,9 @@ X.B(E,Y);return E}return J}())
// srcUrl points at the .md source on the server. fmt is one of // srcUrl points at the .md source on the server. fmt is one of
// "docx" | "html" | "pdf". The server response status maps to a // "docx" | "html" | "pdf". The server response status maps to a
// friendly error message for the caller to surface (toast / status). // friendly error message for the caller to surface (toast / status).
//
// URL grammar: srcUrl is the `<file>.md` source; the converted
// form lives at `<file>.<fmt>` (virtual file extension recognised
// by zddc-server's dispatcher). Replaces the older `?convert=`
// query form.
async function downloadConverted(srcUrl, fileName, fmt) { async function downloadConverted(srcUrl, fileName, fmt) {
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt; var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
var resp = await fetch(convertUrl, { credentials: 'same-origin' }); { credentials: 'same-origin' });
if (!resp.ok) { if (!resp.ok) {
var msg; var msg;
if (resp.status === 503) msg = 'Conversion service unavailable on this server.'; if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
@ -4288,6 +4163,211 @@ X.B(E,Y);return E}return J}())
} }
})(); })();
// shared/nav.js — lateral navigation strip across the project's
// cascade-declared stages. Mounted as a sibling of <header class="app-
// header"> on DOMContentLoaded, hydrated from the project root's
// directory listing.
//
// Stage discovery is cascade-driven (Phase 4c): fetch the project
// root's JSON listing, filter to entries with `declared: true`
// (server stamps these from the .zddc cascade's paths: tree), and
// render in canonical workflow order with display_name overrides
// honored. An operator who edits the project's .zddc paths: to add
// a new declared child sees it in the strip; one who removes a
// canonical entry sees the strip drop it.
//
// When the fetch fails (offline / no-server / file://), the strip
// falls back to the hardcoded four-stage list so existing
// deployments don't lose chrome. Hardcoded labels in this file are
// the LAST resort — the cascade is the source of truth in normal
// operation.
//
// Stage URLs follow the slash/no-slash convention: no slash opens
// the stage's default tool. Operators on non-standard layouts can
// override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
// Hardcoded fallback for offline / file:// / fetch-error contexts.
// Server-driven discovery (FETCH_STAGES below) is the normal path.
var FALLBACK_STAGES = [
{ name: 'archive', label: 'Archive' },
{ name: 'working', label: 'Working' },
{ name: 'staging', label: 'Staging' },
{ name: 'reviewing', label: 'Reviewing' },
];
// Canonical workflow order. Stages appearing in this list are
// rendered in this order; any extras the cascade declares are
// appended alphabetically.
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
for (var i = 0; i < stages.length; i++) {
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
return stages[i].name;
}
}
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function titleCase(s) {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function sortByWorkflow(stages) {
return stages.slice().sort(function (a, b) {
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
if (ia >= 0 && ib >= 0) return ia - ib;
if (ia >= 0) return -1;
if (ib >= 0) return 1;
return a.name.localeCompare(b.name);
});
}
// Fetch the project root listing and extract declared stage
// entries. Returns [] on any error so callers fall back to the
// hardcoded list. Each stage entry is {name, label} — label
// honors the cascade's display: override when present.
async function fetchStagesFor(project) {
try {
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) return [];
var data = await resp.json();
if (!Array.isArray(data)) return [];
var stages = [];
for (var i = 0; i < data.length; i++) {
var e = data[i];
if (!e || !e.declared || !e.is_dir) continue;
var bare = (e.name || '').replace(/\/$/, '');
if (!bare) continue;
stages.push({
name: bare,
label: e.display_name || titleCase(bare),
});
}
return sortByWorkflow(stages);
} catch (_e) {
return [];
}
}
function buildStrip(project, active, stages) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < stages.length; i++) {
var s = stages[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return; // already mounted
}
var active = currentStage(location.pathname, stages);
var strip = buildStrip(project, active, stages);
header.parentNode.insertBefore(strip, header);
}
async function mount() {
if (!shouldRender()) return;
var project = projectSegment(location.pathname);
if (!project) return;
// Render the hardcoded fallback immediately so the strip
// appears with no flicker, then upgrade to cascade-resolved
// stages once the fetch completes.
mountWith(project, FALLBACK_STAGES);
var fetched = await fetchStagesFor(project);
if (fetched.length === 0) return; // fetch failed → keep fallback
// Replace the strip with the cascade-driven one. Remove the
// existing strip first so mountWith re-mounts cleanly.
var existing = document.querySelector('.zddc-stage-strip');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
mountWith(project, fetched);
}
window.zddc.nav = {
mount: mount,
_projectSegment: projectSegment,
_currentStage: currentStage,
_fallbackStages: FALLBACK_STAGES,
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
// shared/logo.js — turn the inert <svg class="app-header__logo"> on // shared/logo.js — turn the inert <svg class="app-header__logo"> on
// every tool's header into a clickable link. The destination is the // every tool's header into a clickable link. The destination is the
// nearest "home" the user can sensibly back out to: // nearest "home" the user can sensibly back out to:
@ -9845,155 +9925,6 @@ X.B(E,Y);return E}return J}())
} }
}()); }());
// shared/elevation.js — admin elevation toggle.
//
// Sudo-style model: admins behave as normal users by default; clicking
// the header toggle elevates the session so admin escape hatches (WORM
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
// State is carried in a `zddc-elevate=1` cookie that the server reads
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
// Only renders the toggle when /.profile/access reports the caller has
// some admin scope — a non-admin sees nothing, which keeps the chrome
// quiet for the common case. The toggle fades in once access loads so
// non-admins never even see the affordance flash.
//
// Click flow: set/clear the cookie, then reload the page so the server
// sees the new state on the next render. The reload is intentional —
// admin scaffolds in tool HTML are server-rendered for some tools, so
// a soft state flip on the client alone wouldn't reach those.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.elevation) return;
var COOKIE_NAME = 'zddc-elevate';
function isElevated() {
var parts = document.cookie.split(';');
for (var i = 0; i < parts.length; i++) {
var kv = parts[i].trim().split('=');
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
}
return false;
}
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
// shapes. Max-Age caps the elevation window so a forgotten
// tab doesn't leave admin powers active indefinitely (sudo's
// 5-minute precedent informs the number — 30 minutes is a
// reasonable trade between annoyance and exposure).
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
} else {
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
}
}
async function fetchAccess() {
try {
var resp = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
function render(host, elevated) {
host.classList.remove('hidden');
host.innerHTML =
'<input type="checkbox" id="elevation-checkbox"'
+ (elevated ? ' checked' : '') + '>'
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
+ 'Admin</label>';
var cb = host.querySelector('#elevation-checkbox');
cb.addEventListener('change', function () {
setElevated(cb.checked);
// Hard reload so server-rendered admin surfaces (profile
// page scaffolds, hidden-entry listings) catch up. URL
// and scroll state are preserved by the browser's normal
// back-forward cache rules.
window.location.reload();
});
}
// Page-wide affordances when elevation is active. The toggle alone
// is easy to miss — admin mode silently bypasses WORM and ACL
// restrictions, which produces surprising "I shouldn't have been
// able to do that" moments. A body class + a sticky banner with a
// one-click disable make the armed state unmistakable.
function applyArmedChrome(elevated) {
var b = document.body;
if (!b) return;
if (elevated) b.classList.add('is-elevated');
else b.classList.remove('is-elevated');
var banner = document.getElementById('elevation-banner');
if (elevated) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'elevation-banner';
banner.className = 'elevation-banner';
banner.setAttribute('role', 'alert');
banner.innerHTML =
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
+ '<span class="elevation-banner__msg">'
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
+ '</span>'
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
+ 'Drop admin'
+ '</button>';
document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off');
if (off) off.addEventListener('click', function () {
setElevated(false);
window.location.reload();
});
}
} else if (banner) {
banner.parentNode.removeChild(banner);
}
}
async function init() {
// Body chrome applies on every page load whether or not the
// header has a toggle slot — the banner needs to surface in
// tools / pages that don't host the toggle (e.g. iframed
// classifier inside browse's grid mode), so the user can't
// accidentally write through an elevated context elsewhere.
applyArmedChrome(isElevated());
var host = document.getElementById('elevation-toggle');
if (!host) return; // tool doesn't include the slot yet — no-op
var access = await fetchAccess();
if (!access) return; // anonymous / endpoint missing — no-op
// Surface ONLY for users who have admin authority somewhere.
// /.profile/access ships `can_elevate` as an elevation-
// INDEPENDENT signal — true for any user named in any admin
// list, regardless of current cookie state. The other flags
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
// authority and would be false for an un-elevated admin
// who hasn't toggled yet — so we can't gate on those.
if (!access.can_elevate) return;
render(host, isElevated());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})();
</script> </script>
</body> </body>
</html> </html>

View file

@ -269,7 +269,7 @@ a:hover {
} }
/* Subdued / de-emphasized variant. /* Subdued / de-emphasized variant.
Used on the "Use Local Directory" button when a tool is operating Used on the "Add Local Directory" button when a tool is operating
in server (online) mode — the local-dir affordance is still in server (online) mode — the local-dir affordance is still
available but visually quieter, since the typical user already available but visually quieter, since the typical user already
has the directory loaded from the server. */ has the directory loaded from the server. */
@ -331,11 +331,6 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
/* Let the left / right groups wrap to a second row at narrow
viewports rather than overflowing the viewport edge. row-gap
gives a small breathing strip when wrapped. */
flex-wrap: wrap;
row-gap: 0.3rem;
} }
/* Left and right groups inside .app-header. Both flex-row so their /* Left and right groups inside .app-header. Both flex-row so their
@ -347,35 +342,16 @@ a:hover {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
/* Allow the title to shrink (and ellipsize) before the action
buttons get pushed off-screen at narrow viewports. */
min-width: 0;
flex-wrap: wrap;
row-gap: 0.3rem;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0;
}
/* Title group (title + build label). Made shrinkable so narrow
viewports don't push the action buttons out of view; the title
itself ellipsizes via the rule below. */
.header-title-group {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
flex-shrink: 1;
} }
/* Tool name inside the header. Renders in the display serif so the /* Tool name inside the header. Renders in the display serif so the
tool's identity reads as a document title, not a UI label. tool's identity reads as a document title, not a UI label. */
overflow + ellipsis on min-width:0 lets the title compress
gracefully when there's no room. */
.app-header__title { .app-header__title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 18px; font-size: 18px;
@ -383,9 +359,6 @@ a:hover {
color: var(--text); color: var(--text);
letter-spacing: 0; letter-spacing: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
/* Brand logo — sits left of the title in every tool's app-header. /* Brand logo — sits left of the title in every tool's app-header.
@ -836,127 +809,61 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
/* shared/elevation.css — admin-elevation toggle in the tool header. /* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
Renders only for users with admin scope (handled by elevation.js; Sits as a sibling immediately under .app-header (mounted by JS).
the placeholder is `.hidden` by default). When visible, sits left Rendered only in online mode when a project segment is in the URL. */
of the theme button — sudo-style affordance for opting into admin
powers. */
.elevation-toggle { .zddc-stage-strip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--text-muted);
user-select: none;
cursor: pointer;
padding: 0.15rem 0.45rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.elevation-toggle:hover {
background: var(--bg-hover);
border-color: var(--border-dark);
}
.elevation-toggle input[type="checkbox"] {
margin: 0;
cursor: pointer;
accent-color: var(--danger);
}
.elevation-toggle__label {
cursor: pointer;
letter-spacing: 0.02em;
}
/* Active state — when elevation is ON, the toggle reads as "armed"
so the user can't miss that admin powers are currently live.
:has(:checked) lets us style the wrapper based on the inner
checkbox without JS. */
.elevation-toggle:has(input:checked) {
background: rgba(220, 53, 69, 0.12);
border-color: var(--danger);
color: var(--danger);
font-weight: 600;
}
/* Page-wide chrome when admin mode is active. The toggle alone is
easy to miss; these add an inescapable visual cue:
1. Thin red border around the entire viewport — peripheral-
vision reminder regardless of which tool / scroll position.
2. Sticky banner across the top with a one-click "Drop admin"
button so the user can disarm without hunting for the toggle.
Both rendered ONLY when the zddc-elevate cookie is set; the
shared/elevation.js init() syncs the body class on every page
load and tears it down when elevation is cleared.
Frame uses fixed positioning + pointer-events:none so it doesn't
reflow content or steal clicks. An inset outline on <body> was
tried first but overdrew content in tools whose root layout butts
right up to the viewport edge (browse split-pane, archive grid). */
body.is-elevated::after {
content: "";
position: fixed;
inset: 0;
border: 3px solid var(--danger, #dc3545);
pointer-events: none;
z-index: 9200; /* above banner (9100) so the frame paints on top */
}
.elevation-banner {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.5rem;
padding: 0.4rem 0.9rem; padding: 0.3rem 1rem;
background: rgba(220, 53, 69, 0.95); background: var(--bg);
color: #fff; border-bottom: 1px solid var(--border);
font-size: 0.85rem; font-size: 0.8rem;
font-weight: 500; line-height: 1.3;
letter-spacing: 0.01em;
position: sticky;
top: 0;
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
.elevation-banner__dot {
width: 0.5rem;
height: 0.5rem;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
animation: elev-pulse 1.6s infinite;
flex-shrink: 0; flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
} }
@keyframes elev-pulse { .zddc-stage-strip__project {
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); } color: var(--text);
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
}
.elevation-banner__msg {
flex: 1 1 auto;
}
.elevation-banner__off {
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.7);
color: #fff;
padding: 0.18rem 0.65rem;
border-radius: var(--radius, 4px);
font-size: 0.78rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; margin-right: 0.15rem;
cursor: pointer;
flex-shrink: 0;
} }
.elevation-banner__off:hover {
background: rgba(255, 255, 255, 0.3); .zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
} }
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor /* shared/logo.css — paired with shared/logo.js. The wrapping anchor
@ -1517,16 +1424,10 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp">v0.0.18</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button> <button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button> <button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div> </div>
@ -1731,7 +1632,6 @@ body {
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
'REC', 'REC',
'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
'TBD',
]; ];
var STATUS_SET = {}; var STATUS_SET = {};
@ -2397,6 +2297,211 @@ body {
} }
})(); })();
// shared/nav.js — lateral navigation strip across the project's
// cascade-declared stages. Mounted as a sibling of <header class="app-
// header"> on DOMContentLoaded, hydrated from the project root's
// directory listing.
//
// Stage discovery is cascade-driven (Phase 4c): fetch the project
// root's JSON listing, filter to entries with `declared: true`
// (server stamps these from the .zddc cascade's paths: tree), and
// render in canonical workflow order with display_name overrides
// honored. An operator who edits the project's .zddc paths: to add
// a new declared child sees it in the strip; one who removes a
// canonical entry sees the strip drop it.
//
// When the fetch fails (offline / no-server / file://), the strip
// falls back to the hardcoded four-stage list so existing
// deployments don't lose chrome. Hardcoded labels in this file are
// the LAST resort — the cascade is the source of truth in normal
// operation.
//
// Stage URLs follow the slash/no-slash convention: no slash opens
// the stage's default tool. Operators on non-standard layouts can
// override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
// Hardcoded fallback for offline / file:// / fetch-error contexts.
// Server-driven discovery (FETCH_STAGES below) is the normal path.
var FALLBACK_STAGES = [
{ name: 'archive', label: 'Archive' },
{ name: 'working', label: 'Working' },
{ name: 'staging', label: 'Staging' },
{ name: 'reviewing', label: 'Reviewing' },
];
// Canonical workflow order. Stages appearing in this list are
// rendered in this order; any extras the cascade declares are
// appended alphabetically.
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
for (var i = 0; i < stages.length; i++) {
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
return stages[i].name;
}
}
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function titleCase(s) {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function sortByWorkflow(stages) {
return stages.slice().sort(function (a, b) {
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
if (ia >= 0 && ib >= 0) return ia - ib;
if (ia >= 0) return -1;
if (ib >= 0) return 1;
return a.name.localeCompare(b.name);
});
}
// Fetch the project root listing and extract declared stage
// entries. Returns [] on any error so callers fall back to the
// hardcoded list. Each stage entry is {name, label} — label
// honors the cascade's display: override when present.
async function fetchStagesFor(project) {
try {
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) return [];
var data = await resp.json();
if (!Array.isArray(data)) return [];
var stages = [];
for (var i = 0; i < data.length; i++) {
var e = data[i];
if (!e || !e.declared || !e.is_dir) continue;
var bare = (e.name || '').replace(/\/$/, '');
if (!bare) continue;
stages.push({
name: bare,
label: e.display_name || titleCase(bare),
});
}
return sortByWorkflow(stages);
} catch (_e) {
return [];
}
}
function buildStrip(project, active, stages) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < stages.length; i++) {
var s = stages[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return; // already mounted
}
var active = currentStage(location.pathname, stages);
var strip = buildStrip(project, active, stages);
header.parentNode.insertBefore(strip, header);
}
async function mount() {
if (!shouldRender()) return;
var project = projectSegment(location.pathname);
if (!project) return;
// Render the hardcoded fallback immediately so the strip
// appears with no flicker, then upgrade to cascade-resolved
// stages once the fetch completes.
mountWith(project, FALLBACK_STAGES);
var fetched = await fetchStagesFor(project);
if (fetched.length === 0) return; // fetch failed → keep fallback
// Replace the strip with the cascade-driven one. Remove the
// existing strip first so mountWith re-mounts cleanly.
var existing = document.querySelector('.zddc-stage-strip');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
mountWith(project, fetched);
}
window.zddc.nav = {
mount: mount,
_projectSegment: projectSegment,
_currentStage: currentStage,
_fallbackStages: FALLBACK_STAGES,
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
// shared/logo.js — turn the inert <svg class="app-header__logo"> on // shared/logo.js — turn the inert <svg class="app-header__logo"> on
// every tool's header into a clickable link. The destination is the // every tool's header into a clickable link. The destination is the
// nearest "home" the user can sensibly back out to: // nearest "home" the user can sensibly back out to:
@ -2527,155 +2632,6 @@ body {
} }
}()); }());
// shared/elevation.js — admin elevation toggle.
//
// Sudo-style model: admins behave as normal users by default; clicking
// the header toggle elevates the session so admin escape hatches (WORM
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
// State is carried in a `zddc-elevate=1` cookie that the server reads
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
// Only renders the toggle when /.profile/access reports the caller has
// some admin scope — a non-admin sees nothing, which keeps the chrome
// quiet for the common case. The toggle fades in once access loads so
// non-admins never even see the affordance flash.
//
// Click flow: set/clear the cookie, then reload the page so the server
// sees the new state on the next render. The reload is intentional —
// admin scaffolds in tool HTML are server-rendered for some tools, so
// a soft state flip on the client alone wouldn't reach those.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.elevation) return;
var COOKIE_NAME = 'zddc-elevate';
function isElevated() {
var parts = document.cookie.split(';');
for (var i = 0; i < parts.length; i++) {
var kv = parts[i].trim().split('=');
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
}
return false;
}
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
// shapes. Max-Age caps the elevation window so a forgotten
// tab doesn't leave admin powers active indefinitely (sudo's
// 5-minute precedent informs the number — 30 minutes is a
// reasonable trade between annoyance and exposure).
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
} else {
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
}
}
async function fetchAccess() {
try {
var resp = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
function render(host, elevated) {
host.classList.remove('hidden');
host.innerHTML =
'<input type="checkbox" id="elevation-checkbox"'
+ (elevated ? ' checked' : '') + '>'
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
+ 'Admin</label>';
var cb = host.querySelector('#elevation-checkbox');
cb.addEventListener('change', function () {
setElevated(cb.checked);
// Hard reload so server-rendered admin surfaces (profile
// page scaffolds, hidden-entry listings) catch up. URL
// and scroll state are preserved by the browser's normal
// back-forward cache rules.
window.location.reload();
});
}
// Page-wide affordances when elevation is active. The toggle alone
// is easy to miss — admin mode silently bypasses WORM and ACL
// restrictions, which produces surprising "I shouldn't have been
// able to do that" moments. A body class + a sticky banner with a
// one-click disable make the armed state unmistakable.
function applyArmedChrome(elevated) {
var b = document.body;
if (!b) return;
if (elevated) b.classList.add('is-elevated');
else b.classList.remove('is-elevated');
var banner = document.getElementById('elevation-banner');
if (elevated) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'elevation-banner';
banner.className = 'elevation-banner';
banner.setAttribute('role', 'alert');
banner.innerHTML =
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
+ '<span class="elevation-banner__msg">'
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
+ '</span>'
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
+ 'Drop admin'
+ '</button>';
document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off');
if (off) off.addEventListener('click', function () {
setElevated(false);
window.location.reload();
});
}
} else if (banner) {
banner.parentNode.removeChild(banner);
}
}
async function init() {
// Body chrome applies on every page load whether or not the
// header has a toggle slot — the banner needs to surface in
// tools / pages that don't host the toggle (e.g. iframed
// classifier inside browse's grid mode), so the user can't
// accidentally write through an elevated context elsewhere.
applyArmedChrome(isElevated());
var host = document.getElementById('elevation-toggle');
if (!host) return; // tool doesn't include the slot yet — no-op
var access = await fetchAccess();
if (!access) return; // anonymous / endpoint missing — no-op
// Surface ONLY for users who have admin authority somewhere.
// /.profile/access ships `can_elevate` as an elevation-
// INDEPENDENT signal — true for any user named in any admin
// list, regardless of current cookie state. The other flags
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
// authority and would be false for an un-elevated admin
// who hasn't toggled yet — so we can't gate on those.
if (!access.can_elevate) return;
render(host, isElevated());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})();
(function() { (function() {
'use strict'; 'use strict';
// ZDDC landing page — project picker. // ZDDC landing page — project picker.
@ -2806,27 +2762,13 @@ body {
var data = JSON.parse(body); var data = JSON.parse(body);
if (!Array.isArray(data)) throw new Error('Expected a JSON array of projects, got ' + typeof data); if (!Array.isArray(data)) throw new Error('Expected a JSON array of projects, got ' + typeof data);
// The root JSON is now a generic listing.FileInfo[] (same allProjects = data.map(function(p) {
// shape every other directory returns). Filter to
// directories (projects are folders), strip the trailing
// "/" the server adds to dir names, and pick up `title`
// (the per-project .zddc title:, populated by the
// server-side listing pipeline).
allProjects = data
.filter(function (p) { return p && p.is_dir; })
.map(function (p) {
var raw = String(p.name || '').replace(/\/$/, '');
return { return {
name: raw, name: String(p.name || ''),
title: String(p.title || ''), title: String(p.title || ''),
url: String(p.url || '') url: String(p.url || '')
}; };
}) }).filter(function(p) { return p.name; });
.filter(function (p) {
if (!p.name) return false;
var c = p.name.charAt(0);
return c !== '.' && c !== '_';
});
return true; return true;
} catch (e) { } catch (e) {
loadError = e.message || String(e); loadError = e.message || String(e);

View file

@ -273,7 +273,7 @@ a:hover {
} }
/* Subdued / de-emphasized variant. /* Subdued / de-emphasized variant.
Used on the "Use Local Directory" button when a tool is operating Used on the "Add Local Directory" button when a tool is operating
in server (online) mode — the local-dir affordance is still in server (online) mode — the local-dir affordance is still
available but visually quieter, since the typical user already available but visually quieter, since the typical user already
has the directory loaded from the server. */ has the directory loaded from the server. */
@ -335,11 +335,6 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
/* Let the left / right groups wrap to a second row at narrow
viewports rather than overflowing the viewport edge. row-gap
gives a small breathing strip when wrapped. */
flex-wrap: wrap;
row-gap: 0.3rem;
} }
/* Left and right groups inside .app-header. Both flex-row so their /* Left and right groups inside .app-header. Both flex-row so their
@ -351,35 +346,16 @@ a:hover {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
/* Allow the title to shrink (and ellipsize) before the action
buttons get pushed off-screen at narrow viewports. */
min-width: 0;
flex-wrap: wrap;
row-gap: 0.3rem;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0;
}
/* Title group (title + build label). Made shrinkable so narrow
viewports don't push the action buttons out of view; the title
itself ellipsizes via the rule below. */
.header-title-group {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
flex-shrink: 1;
} }
/* Tool name inside the header. Renders in the display serif so the /* Tool name inside the header. Renders in the display serif so the
tool's identity reads as a document title, not a UI label. tool's identity reads as a document title, not a UI label. */
overflow + ellipsis on min-width:0 lets the title compress
gracefully when there's no room. */
.app-header__title { .app-header__title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 18px; font-size: 18px;
@ -387,9 +363,6 @@ a:hover {
color: var(--text); color: var(--text);
letter-spacing: 0; letter-spacing: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
/* Brand logo — sits left of the title in every tool's app-header. /* Brand logo — sits left of the title in every tool's app-header.
@ -840,127 +813,61 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
/* shared/elevation.css — admin-elevation toggle in the tool header. /* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
Renders only for users with admin scope (handled by elevation.js; Sits as a sibling immediately under .app-header (mounted by JS).
the placeholder is `.hidden` by default). When visible, sits left Rendered only in online mode when a project segment is in the URL. */
of the theme button — sudo-style affordance for opting into admin
powers. */
.elevation-toggle { .zddc-stage-strip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--text-muted);
user-select: none;
cursor: pointer;
padding: 0.15rem 0.45rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.elevation-toggle:hover {
background: var(--bg-hover);
border-color: var(--border-dark);
}
.elevation-toggle input[type="checkbox"] {
margin: 0;
cursor: pointer;
accent-color: var(--danger);
}
.elevation-toggle__label {
cursor: pointer;
letter-spacing: 0.02em;
}
/* Active state — when elevation is ON, the toggle reads as "armed"
so the user can't miss that admin powers are currently live.
:has(:checked) lets us style the wrapper based on the inner
checkbox without JS. */
.elevation-toggle:has(input:checked) {
background: rgba(220, 53, 69, 0.12);
border-color: var(--danger);
color: var(--danger);
font-weight: 600;
}
/* Page-wide chrome when admin mode is active. The toggle alone is
easy to miss; these add an inescapable visual cue:
1. Thin red border around the entire viewport — peripheral-
vision reminder regardless of which tool / scroll position.
2. Sticky banner across the top with a one-click "Drop admin"
button so the user can disarm without hunting for the toggle.
Both rendered ONLY when the zddc-elevate cookie is set; the
shared/elevation.js init() syncs the body class on every page
load and tears it down when elevation is cleared.
Frame uses fixed positioning + pointer-events:none so it doesn't
reflow content or steal clicks. An inset outline on <body> was
tried first but overdrew content in tools whose root layout butts
right up to the viewport edge (browse split-pane, archive grid). */
body.is-elevated::after {
content: "";
position: fixed;
inset: 0;
border: 3px solid var(--danger, #dc3545);
pointer-events: none;
z-index: 9200; /* above banner (9100) so the frame paints on top */
}
.elevation-banner {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.5rem;
padding: 0.4rem 0.9rem; padding: 0.3rem 1rem;
background: rgba(220, 53, 69, 0.95); background: var(--bg);
color: #fff; border-bottom: 1px solid var(--border);
font-size: 0.85rem; font-size: 0.8rem;
font-weight: 500; line-height: 1.3;
letter-spacing: 0.01em;
position: sticky;
top: 0;
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
.elevation-banner__dot {
width: 0.5rem;
height: 0.5rem;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
animation: elev-pulse 1.6s infinite;
flex-shrink: 0; flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
} }
@keyframes elev-pulse { .zddc-stage-strip__project {
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); } color: var(--text);
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
}
.elevation-banner__msg {
flex: 1 1 auto;
}
.elevation-banner__off {
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.7);
color: #fff;
padding: 0.18rem 0.65rem;
border-radius: var(--radius, 4px);
font-size: 0.78rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; margin-right: 0.15rem;
cursor: pointer;
flex-shrink: 0;
} }
.elevation-banner__off:hover {
background: rgba(255, 255, 255, 0.3); .zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
} }
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor /* shared/logo.css — paired with shared/logo.js. The wrapping anchor
@ -2616,11 +2523,11 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp">v0.0.18</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;
other tools have "Use Local Directory" here instead) --> other tools have "Add Local Directory" here instead) -->
<div class="split-button" id="bottom-menu" hidden> <div class="split-button" id="bottom-menu" hidden>
<button id="bottom-toggle" type="button" class="btn btn-primary split-button__toggle" data-no-disable="true" aria-haspopup="true" aria-expanded="false">&#x25BE;</button> <button id="bottom-toggle" type="button" class="btn btn-primary split-button__toggle" data-no-disable="true" aria-haspopup="true" aria-expanded="false">&#x25BE;</button>
<button id="bottom-primary" type="button" class="btn btn-primary" data-no-disable="true">Publish</button> <button id="bottom-primary" type="button" class="btn btn-primary" data-no-disable="true">Publish</button>
@ -2628,12 +2535,6 @@ dialog.modal--narrow {
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button type="button" id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button> <button type="button" id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button type="button" id="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button> <button type="button" id="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button>
</div> </div>
@ -4301,7 +4202,6 @@ X.B(E,Y);return E}return J}())
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
'REC', 'REC',
'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
'TBD',
]; ];
var STATUS_SET = {}; var STATUS_SET = {};
@ -5030,34 +4930,14 @@ X.B(E,Y);return E}return J}())
// Top-level helpers // Top-level helpers
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Resolve "the directory the tool was opened in" for the current // Strip a trailing tool .html (e.g. classifier.html) from a path
// page URL. Two URL shapes serve a tool: // to land on the "directory the tool was opened in".
//
// /…/<tool>.html — file URL; strip the trailing filename.
// /…/<dir>/ — trailing-slash directory URL; keep it.
// /…/<dir> — bare-directory URL served by the
// cascade's `default_tool` (e.g.
// archive/<party>/mdl serves the tables
// tool). Treat as the directory itself
// and append the missing slash.
//
// Discrimination is "does the last segment contain a dot?" — a dot
// is a reliable proxy for "looks like a file with an extension"
// since neither directory names nor default_tool paths contain
// them in this system.
function pathToDir(pathname) { function pathToDir(pathname) {
if (!pathname) return '/'; if (!pathname) return '/';
if (pathname.endsWith('/')) return pathname; if (pathname.endsWith('/')) return pathname;
var slash = pathname.lastIndexOf('/'); var slash = pathname.lastIndexOf('/');
var lastSeg = slash >= 0 ? pathname.substring(slash + 1) : pathname;
if (lastSeg.indexOf('.') !== -1) {
// Has an extension → looks like a file URL → strip the
// filename to land on the parent directory.
return slash >= 0 ? pathname.substring(0, slash + 1) : '/'; return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
} }
// No extension → the URL IS the directory; just close it.
return pathname + '/';
}
// Probe the server-mode root for the current page. Returns: // Probe the server-mode root for the current page. Returns:
// //
@ -5136,14 +5016,9 @@ X.B(E,Y);return E}return J}())
// srcUrl points at the .md source on the server. fmt is one of // srcUrl points at the .md source on the server. fmt is one of
// "docx" | "html" | "pdf". The server response status maps to a // "docx" | "html" | "pdf". The server response status maps to a
// friendly error message for the caller to surface (toast / status). // friendly error message for the caller to surface (toast / status).
//
// URL grammar: srcUrl is the `<file>.md` source; the converted
// form lives at `<file>.<fmt>` (virtual file extension recognised
// by zddc-server's dispatcher). Replaces the older `?convert=`
// query form.
async function downloadConverted(srcUrl, fileName, fmt) { async function downloadConverted(srcUrl, fileName, fmt) {
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt; var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
var resp = await fetch(convertUrl, { credentials: 'same-origin' }); { credentials: 'same-origin' });
if (!resp.ok) { if (!resp.ok) {
var msg; var msg;
if (resp.status === 503) msg = 'Conversion service unavailable on this server.'; if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
@ -5344,6 +5219,211 @@ X.B(E,Y);return E}return J}())
} }
})(); })();
// shared/nav.js — lateral navigation strip across the project's
// cascade-declared stages. Mounted as a sibling of <header class="app-
// header"> on DOMContentLoaded, hydrated from the project root's
// directory listing.
//
// Stage discovery is cascade-driven (Phase 4c): fetch the project
// root's JSON listing, filter to entries with `declared: true`
// (server stamps these from the .zddc cascade's paths: tree), and
// render in canonical workflow order with display_name overrides
// honored. An operator who edits the project's .zddc paths: to add
// a new declared child sees it in the strip; one who removes a
// canonical entry sees the strip drop it.
//
// When the fetch fails (offline / no-server / file://), the strip
// falls back to the hardcoded four-stage list so existing
// deployments don't lose chrome. Hardcoded labels in this file are
// the LAST resort — the cascade is the source of truth in normal
// operation.
//
// Stage URLs follow the slash/no-slash convention: no slash opens
// the stage's default tool. Operators on non-standard layouts can
// override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
// Hardcoded fallback for offline / file:// / fetch-error contexts.
// Server-driven discovery (FETCH_STAGES below) is the normal path.
var FALLBACK_STAGES = [
{ name: 'archive', label: 'Archive' },
{ name: 'working', label: 'Working' },
{ name: 'staging', label: 'Staging' },
{ name: 'reviewing', label: 'Reviewing' },
];
// Canonical workflow order. Stages appearing in this list are
// rendered in this order; any extras the cascade declares are
// appended alphabetically.
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
for (var i = 0; i < stages.length; i++) {
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
return stages[i].name;
}
}
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function titleCase(s) {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function sortByWorkflow(stages) {
return stages.slice().sort(function (a, b) {
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
if (ia >= 0 && ib >= 0) return ia - ib;
if (ia >= 0) return -1;
if (ib >= 0) return 1;
return a.name.localeCompare(b.name);
});
}
// Fetch the project root listing and extract declared stage
// entries. Returns [] on any error so callers fall back to the
// hardcoded list. Each stage entry is {name, label} — label
// honors the cascade's display: override when present.
async function fetchStagesFor(project) {
try {
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) return [];
var data = await resp.json();
if (!Array.isArray(data)) return [];
var stages = [];
for (var i = 0; i < data.length; i++) {
var e = data[i];
if (!e || !e.declared || !e.is_dir) continue;
var bare = (e.name || '').replace(/\/$/, '');
if (!bare) continue;
stages.push({
name: bare,
label: e.display_name || titleCase(bare),
});
}
return sortByWorkflow(stages);
} catch (_e) {
return [];
}
}
function buildStrip(project, active, stages) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < stages.length; i++) {
var s = stages[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return; // already mounted
}
var active = currentStage(location.pathname, stages);
var strip = buildStrip(project, active, stages);
header.parentNode.insertBefore(strip, header);
}
async function mount() {
if (!shouldRender()) return;
var project = projectSegment(location.pathname);
if (!project) return;
// Render the hardcoded fallback immediately so the strip
// appears with no flicker, then upgrade to cascade-resolved
// stages once the fetch completes.
mountWith(project, FALLBACK_STAGES);
var fetched = await fetchStagesFor(project);
if (fetched.length === 0) return; // fetch failed → keep fallback
// Replace the strip with the cascade-driven one. Remove the
// existing strip first so mountWith re-mounts cleanly.
var existing = document.querySelector('.zddc-stage-strip');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
mountWith(project, fetched);
}
window.zddc.nav = {
mount: mount,
_projectSegment: projectSegment,
_currentStage: currentStage,
_fallbackStages: FALLBACK_STAGES,
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
// shared/logo.js — turn the inert <svg class="app-header__logo"> on // shared/logo.js — turn the inert <svg class="app-header__logo"> on
// every tool's header into a clickable link. The destination is the // every tool's header into a clickable link. The destination is the
// nearest "home" the user can sensibly back out to: // nearest "home" the user can sensibly back out to:
@ -13269,155 +13349,6 @@ X.B(E,Y);return E}return J}())
} }
}()); }());
// shared/elevation.js — admin elevation toggle.
//
// Sudo-style model: admins behave as normal users by default; clicking
// the header toggle elevates the session so admin escape hatches (WORM
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
// State is carried in a `zddc-elevate=1` cookie that the server reads
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
// Only renders the toggle when /.profile/access reports the caller has
// some admin scope — a non-admin sees nothing, which keeps the chrome
// quiet for the common case. The toggle fades in once access loads so
// non-admins never even see the affordance flash.
//
// Click flow: set/clear the cookie, then reload the page so the server
// sees the new state on the next render. The reload is intentional —
// admin scaffolds in tool HTML are server-rendered for some tools, so
// a soft state flip on the client alone wouldn't reach those.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.elevation) return;
var COOKIE_NAME = 'zddc-elevate';
function isElevated() {
var parts = document.cookie.split(';');
for (var i = 0; i < parts.length; i++) {
var kv = parts[i].trim().split('=');
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
}
return false;
}
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
// shapes. Max-Age caps the elevation window so a forgotten
// tab doesn't leave admin powers active indefinitely (sudo's
// 5-minute precedent informs the number — 30 minutes is a
// reasonable trade between annoyance and exposure).
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
} else {
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
}
}
async function fetchAccess() {
try {
var resp = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
function render(host, elevated) {
host.classList.remove('hidden');
host.innerHTML =
'<input type="checkbox" id="elevation-checkbox"'
+ (elevated ? ' checked' : '') + '>'
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
+ 'Admin</label>';
var cb = host.querySelector('#elevation-checkbox');
cb.addEventListener('change', function () {
setElevated(cb.checked);
// Hard reload so server-rendered admin surfaces (profile
// page scaffolds, hidden-entry listings) catch up. URL
// and scroll state are preserved by the browser's normal
// back-forward cache rules.
window.location.reload();
});
}
// Page-wide affordances when elevation is active. The toggle alone
// is easy to miss — admin mode silently bypasses WORM and ACL
// restrictions, which produces surprising "I shouldn't have been
// able to do that" moments. A body class + a sticky banner with a
// one-click disable make the armed state unmistakable.
function applyArmedChrome(elevated) {
var b = document.body;
if (!b) return;
if (elevated) b.classList.add('is-elevated');
else b.classList.remove('is-elevated');
var banner = document.getElementById('elevation-banner');
if (elevated) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'elevation-banner';
banner.className = 'elevation-banner';
banner.setAttribute('role', 'alert');
banner.innerHTML =
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
+ '<span class="elevation-banner__msg">'
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
+ '</span>'
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
+ 'Drop admin'
+ '</button>';
document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off');
if (off) off.addEventListener('click', function () {
setElevated(false);
window.location.reload();
});
}
} else if (banner) {
banner.parentNode.removeChild(banner);
}
}
async function init() {
// Body chrome applies on every page load whether or not the
// header has a toggle slot — the banner needs to surface in
// tools / pages that don't host the toggle (e.g. iframed
// classifier inside browse's grid mode), so the user can't
// accidentally write through an elevated context elsewhere.
applyArmedChrome(isElevated());
var host = document.getElementById('elevation-toggle');
if (!host) return; // tool doesn't include the slot yet — no-op
var access = await fetchAccess();
if (!access) return; // anonymous / endpoint missing — no-op
// Surface ONLY for users who have admin authority somewhere.
// /.profile/access ships `can_elevate` as an elevation-
// INDEPENDENT signal — true for any user named in any admin
// list, regardless of current cookie state. The other flags
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
// authority and would be false for an un-elevated admin
// who hasn't toggled yet — so we can't gate on those.
if (!access.can_elevate) return;
render(host, isElevated());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})();
(function (app) { (function (app) {
'use strict'; 'use strict';

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.18 archive=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
transmittal=v0.0.18 transmittal=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
classifier=v0.0.18 classifier=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
landing=v0.0.18 landing=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
form=v0.0.18 form=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
tables=v0.0.18 tables=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
browse=v0.0.18 browse=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552

View file

@ -221,15 +221,15 @@ func TestServer_FetchFailFallsBackToEmbedded(t *testing.T) {
func TestServer_VParam_CacheHitServesFromCache(t *testing.T) { func TestServer_VParam_CacheHitServesFromCache(t *testing.T) {
srv, _, root := newTestServer(t, []byte("ignored")) srv, _, root := newTestServer(t, []byte("ignored"))
// Pre-populate the cache with a known URL. // Pre-populate the cache with a known URL.
cachedURL := "https://zddc.varasys.io/releases/archive_v0.0.4.html" cachedURL := "https://zddc.varasys.io/releases/archive_beta.html"
cachedBody := []byte("CACHED v0.0.4 archive") cachedBody := []byte("CACHED beta archive")
if err := srv.Cache.Write(cachedURL, cachedBody); err != nil { if err := srv.Cache.Write(cachedURL, cachedBody); err != nil {
t.Fatal(err) t.Fatal(err)
} }
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}} chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=v0.0.4", nil), "archive", chain, root) srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=beta", nil), "archive", chain, root)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
} }
@ -245,7 +245,7 @@ func TestServer_VParam_CacheMissReturns404(t *testing.T) {
srv, _, root := newTestServer(t, []byte("ignored")) srv, _, root := newTestServer(t, []byte("ignored"))
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}} chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=v0.0.4", nil), "archive", chain, root) srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=beta", nil), "archive", chain, root)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Fatalf("status=%d (want 404)", rec.Code) t.Fatalf("status=%d (want 404)", rec.Code)
} }
@ -277,19 +277,19 @@ func TestServer_VParam_BadSpecReturns400(t *testing.T) {
func TestServer_VParam_CombinesWithCascadeURLPrefix(t *testing.T) { func TestServer_VParam_CombinesWithCascadeURLPrefix(t *testing.T) {
// Cascade has a default URL prefix; ?v=:beta should resolve against it. // Cascade has a default URL prefix; ?v=:beta should resolve against it.
srv, _, root := newTestServer(t, []byte("ignored")) srv, _, root := newTestServer(t, []byte("ignored"))
cachedURL := "https://my-mirror.example/releases/archive_v0.0.4.html" cachedURL := "https://my-mirror.example/releases/archive_beta.html"
if err := srv.Cache.Write(cachedURL, []byte("MIRROR v0.0.4")); err != nil { if err := srv.Cache.Write(cachedURL, []byte("MIRROR beta")); err != nil {
t.Fatal(err) t.Fatal(err)
} }
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{ chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"default": "https://my-mirror.example/releases:stable"}, Apps: map[string]string{"default": "https://my-mirror.example/releases:stable"},
}}} }}}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=:v0.0.4", nil), "archive", chain, root) srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=:beta", nil), "archive", chain, root)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
} }
if rec.Body.String() != "MIRROR v0.0.4" { if rec.Body.String() != "MIRROR beta" {
t.Errorf("body=%q", rec.Body.String()) t.Errorf("body=%q", rec.Body.String())
} }
if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL { if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL {
@ -299,9 +299,9 @@ func TestServer_VParam_CombinesWithCascadeURLPrefix(t *testing.T) {
func TestServer_VParam_OverridesPathTerminalFromCascade(t *testing.T) { func TestServer_VParam_OverridesPathTerminalFromCascade(t *testing.T) {
// Operator's cascade specifies a path source. User passes ?v=stable. // Operator's cascade specifies a path source. User passes ?v=stable.
// ?v= overrides → resolves to canonical/archive.html, then cache check. // ?v= overrides → resolves to canonical/archive_stable.html, then cache check.
srv, _, root := newTestServer(t, []byte("ignored")) srv, _, root := newTestServer(t, []byte("ignored"))
cachedURL := "https://zddc.varasys.io/releases/archive.html" cachedURL := "https://zddc.varasys.io/releases/archive_stable.html"
if err := srv.Cache.Write(cachedURL, []byte("CACHED stable")); err != nil { if err := srv.Cache.Write(cachedURL, []byte("CACHED stable")); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View file

@ -8,7 +8,7 @@ import (
// embeddedVersionsRaw is the manifest written by the top-level build.sh // embeddedVersionsRaw is the manifest written by the top-level build.sh
// at compile time. Format is one `<app>=<build label>` line per app — // at compile time. Format is one `<app>=<build label>` line per app —
// e.g. `archive=v0.0.5-beta · 2026-05-01 14:00:00 · abc1234`. An empty // e.g. `archive=v0.0.5-alpha · 2026-05-01 14:00:00 · abc1234`. An empty
// or missing value indicates the embedded slot was not populated (a fresh // or missing value indicates the embedded slot was not populated (a fresh
// clone where build.sh hasn't run yet). // clone where build.sh hasn't run yet).
// //

View file

@ -264,8 +264,7 @@ func Load(args []string) (Config, error) {
return Config{}, fmt.Errorf( return Config{}, fmt.Errorf(
"no %s/.zddc file found; the served tree would be publicly accessible to anonymous callers. "+ "no %s/.zddc file found; the served tree would be publicly accessible to anonymous callers. "+
"Create a starter .zddc (at minimum: `admins: [you@yourcompany.com]`) "+ "Create a starter .zddc (at minimum: `admins: [you@yourcompany.com]`) "+
"or pass --insecure (or ZDDC_INSECURE=1) to acknowledge a deliberately-public deployment. "+ "or pass --insecure (or ZDDC_INSECURE=1) to acknowledge a deliberately-public deployment",
"See README.md '## Deploy: bootstrap config' or AGENTS.md '## zddc-server / ### Bootstrap config'",
cfg.Root) cfg.Root)
} else if err != nil { } else if err != nil {
return Config{}, fmt.Errorf("could not stat %s/.zddc: %w", cfg.Root, err) return Config{}, fmt.Errorf("could not stat %s/.zddc: %w", cfg.Root, err)

View file

@ -9,13 +9,7 @@
# (free-text strings, no regex / enum constraints) — projects choose # (free-text strings, no regex / enum constraints) — projects choose
# their own conventions for originator codes, discipline vocabularies, # their own conventions for originator codes, discipline vocabularies,
# etc., and a default that imposed a fixed set would just get in the # etc., and a default that imposed a fixed set would just get in the
# way. Tightening per project is done via .zddc field_codes:, which # way.
# the cascade resolves before the form is rendered.
#
# The six audit fields at the bottom are server-managed: clients must
# render them as read-only and never submit values for them.
# WriteWithHistory strips any client-supplied audit fields before
# schema validation and re-injects the authoritative values.
# #
# To customize: drop your own form.yaml into archive/<party>/mdl/ # To customize: drop your own form.yaml into archive/<party>/mdl/
# (the same directory as table.yaml). Tighten constraints with # (the same directory as table.yaml). Tighten constraints with
@ -97,40 +91,6 @@ schema:
notes: notes:
type: string type: string
title: Notes title: Notes
# --- Audit fields (server-managed; clients must not submit
# values). WriteWithHistory strips any client-supplied versions
# before validation and re-injects authoritative values.
created_at:
type: string
title: Created
format: date-time
readOnly: true
created_by:
type: string
title: Created by
format: email
readOnly: true
updated_at:
type: string
title: Updated
format: date-time
readOnly: true
updated_by:
type: string
title: Updated by
format: email
readOnly: true
revision:
type: integer
title: Revision
minimum: 1
readOnly: true
previous_sha:
type: string
title: Previous SHA
description: SHA-256 (first 8 hex chars) of the prior revision's bytes.
readOnly: true
ui: ui:
notes: notes:
ui:widget: textarea ui:widget: textarea

View file

@ -1,122 +0,0 @@
# Default project-rollup MDL row schema, served by zddc-server when
# no operator-supplied form.yaml exists at <project>/mdl/.
#
# Identical to the per-party MDL schema (default-mdl.form.yaml)
# except for one extra required field: `party`. That field is the
# routing key — the server reads it on POST <project>/mdl/form.html,
# finds the matching <project>/archive/<party>/ folder, and writes
# the row inside its mdl/ subfolder. The `party` value is stripped
# from the YAML on write (folder name IS the identity); on read the
# dispatcher injects it back so the rollup table can show the
# Package column.
#
# To customize: drop your own form.yaml at <project>/mdl/form.yaml.
# Keep the `party` field shape unless you also customize the rollup
# create handler — the server's routing depends on it.
title: Deliverable (project rollup)
description: One deliverable across all parties. The first field (Package) routes the row to the matching archive/<party>/mdl/ folder; the rest mirrors the per-party MDL schema.
schema:
type: object
required: [party, originator, project, discipline, type, sequence, title]
additionalProperties: false
properties:
party:
type: string
title: Package (party folder)
description: Routing key — must match an existing <project>/archive/<party>/ folder. Typical naming = MasterFormat 4-digit code + C|P + sequence digit (e.g. 0330C1).
pattern: "^[A-Za-z0-9][A-Za-z0-9.-]*$"
minLength: 1
originator:
type: string
title: Originator
description: Organizational unit responsible for this deliverable (e.g. ACME).
minLength: 1
phase:
type: string
title: Phase
description: Optional project phase code (e.g. ECI, EPC).
project:
type: string
title: Project
description: Project identifier, or your corporate placeholder for non-project deliverables.
minLength: 1
area:
type: string
title: Area
description: Optional area / budget code (e.g. B02).
discipline:
type: string
title: Discipline
description: Engineering or functional group code (EL, ME, CV, PM, ...).
minLength: 1
type:
type: string
title: Document type
description: Document category code within the discipline (SPC, DWG, RPT, ...).
minLength: 1
sequence:
type: string
title: Sequence
description: Zero-padded integer (0001, 0042, 2623). Stored as a string so leading zeros survive YAML.
minLength: 1
suffix:
type: string
title: Suffix
description: Optional structural-part suffix.
title:
type: string
title: Deliverable title
minLength: 1
plannedRevision:
type: string
title: Planned revision
plannedDate:
type: string
title: Planned date
format: date
status:
type: string
title: Current status
enum: [DFT, IFR, IFA, IFC, AFC, AB]
owner:
type: string
title: Owner
notes:
type: string
title: Notes
# --- Audit fields (server-managed; read-only).
created_at:
type: string
title: Created
format: date-time
readOnly: true
created_by:
type: string
title: Created by
format: email
readOnly: true
updated_at:
type: string
title: Updated
format: date-time
readOnly: true
updated_by:
type: string
title: Updated by
format: email
readOnly: true
revision:
type: integer
title: Revision
minimum: 1
readOnly: true
previous_sha:
type: string
title: Previous SHA
readOnly: true
ui:
notes:
ui:widget: textarea

View file

@ -8,14 +8,15 @@
# column is derived from the row's source folder (path-injected by # column is derived from the row's source folder (path-injected by
# the server, not stored in the YAML). # the server, not stored in the YAML).
# #
# + Add row IS enabled here: the `party` column doubles as the # + Add row is suppressed in this view because the party affiliation
# routing key — the server reads the submitted `party` field, finds # would be ambiguous — add deliverables at the per-party path
# the matching <project>/archive/<party>/ folder, and writes the row # (<project>/archive/<party>/mdl/) and they'll appear here on next
# inside its mdl/ subfolder. The party folder must already exist # load.
# (create it via the SSR view).
title: Project Deliverables (all parties) title: Project Deliverables (all parties)
description: Every deliverable across all parties under archive/. Click a row to edit; + Add row uses the Package column to route the new row to the matching archive/<party>/mdl/ folder. description: Every deliverable across all parties under archive/. Click a row to edit; add rows at the per-party MDL view.
addable: false
columns: columns:
- field: party - field: party

View file

@ -1,153 +0,0 @@
# Default project-rollup RSK row schema, served by zddc-server when
# no operator-supplied form.yaml exists at <project>/rsk/.
#
# Identical to the per-party RSK schema (default-rsk.form.yaml)
# except for one extra required field: `party`. The server reads it
# on POST <project>/rsk/form.html and routes the row to the matching
# <project>/archive/<party>/rsk/ folder. The `party` value is
# stripped from the YAML on write (folder name IS the identity); on
# read the dispatcher injects it back.
#
# To customize: drop your own form.yaml at <project>/rsk/form.yaml.
# Keep the `party` field shape unless you also customize the rollup
# create handler.
title: Risk (project rollup)
description: One risk across all parties. The first field (Package) routes the row to the matching archive/<party>/rsk/ folder; the rest mirrors the per-party RSK schema.
schema:
type: object
# `type` is intentionally absent from required: — the cascade's
# field_defaults inject type=RSK after schema validation, and the
# form renderer surfaces it as a locked readOnly field. Requiring
# it here would 422 well-behaved clients that omit the cascade-
# owned field.
required: [party, originator, project, discipline, sequence, title]
additionalProperties: false
properties:
party:
type: string
title: Package (party folder)
description: Routing key — must match an existing <project>/archive/<party>/ folder.
pattern: "^[A-Za-z0-9][A-Za-z0-9.-]*$"
minLength: 1
# --- Table-tracking components (same shape as the per-party rsk
# schema). Together with `row` they compose the filename.
originator:
type: string
title: Originator
minLength: 1
phase:
type: string
title: Phase
project:
type: string
title: Project
minLength: 1
area:
type: string
title: Area
discipline:
type: string
title: Discipline
minLength: 1
type:
type: string
title: Document type
description: Locked to RSK by the cascade; the form renders this read-only.
enum: [RSK]
sequence:
type: string
title: Sequence
minLength: 1
suffix:
type: string
title: Suffix
row:
type: string
title: Row
description: Zero-padded sequence within the parent register. Server-assigned.
readOnly: true
# --- Risk-level data.
title:
type: string
title: Risk
minLength: 1
category:
type: string
title: Category
description:
type: string
title: Description
likelihood:
type: integer
title: Likelihood
minimum: 1
maximum: 5
impact:
type: integer
title: Impact
minimum: 1
maximum: 5
severity:
type: integer
title: Severity
minimum: 1
maximum: 25
mitigation:
type: string
title: Mitigation
owner:
type: string
title: Owner
status:
type: string
title: Status
enum: [open, mitigated, accepted, closed]
dueDate:
type: string
title: Due date
format: date
notes:
type: string
title: Notes
# --- Audit fields (server-managed; read-only).
created_at:
type: string
title: Created
format: date-time
readOnly: true
created_by:
type: string
title: Created by
format: email
readOnly: true
updated_at:
type: string
title: Updated
format: date-time
readOnly: true
updated_by:
type: string
title: Updated by
format: email
readOnly: true
revision:
type: integer
title: Revision
minimum: 1
readOnly: true
previous_sha:
type: string
title: Previous SHA
readOnly: true
ui:
description:
ui:widget: textarea
mitigation:
ui:widget: textarea
notes:
ui:widget: textarea

View file

@ -7,14 +7,15 @@
# column is derived from the row's source folder (path-injected by # column is derived from the row's source folder (path-injected by
# the server, not stored in the YAML). # the server, not stored in the YAML).
# #
# + Add row IS enabled here: the `party` column doubles as the # + Add row is suppressed in this view because the party affiliation
# routing key — the server reads the submitted `party` field, finds # would be ambiguous — add risks at the per-party path
# the matching <project>/archive/<party>/ folder, and writes the row # (<project>/archive/<party>/rsk/) and they'll appear here on next
# inside its rsk/ subfolder. The party folder must already exist # load.
# (create it via the SSR view).
title: Project Risk Register (all parties) title: Project Risk Register (all parties)
description: Every risk across all parties under archive/. Click a row to edit; + Add row uses the Package column to route the new row to the matching archive/<party>/rsk/ folder. description: Every risk across all parties under archive/. Click a row to edit; add rows at the per-party RSK view.
addable: false
columns: columns:
- field: party - field: party

View file

@ -2,29 +2,11 @@
# zddc-server when no operator-supplied form.yaml exists at # zddc-server when no operator-supplied form.yaml exists at
# archive/<party>/rsk/. # archive/<party>/rsk/.
# #
# The risk register is structurally different from MDL: the RSK
# TABLE is itself a tracked deliverable (with its own tracking
# number — same shape as an MDL deliverable, type locked to RSK by
# the cascade), and each row in the table is a CHILD of that
# deliverable identified by a per-row sequence (`row`). The row's
# filename = <table-tracking>-<row>.yaml, composed by the server
# from the components below.
#
# Why the table-tracking components live on every row: the row .yaml
# is self-describing — you can pick up any single file and identify
# both the deliverable it contributes to AND its position within
# that deliverable. Multiple RSK tables (different table-tracking
# numbers) can coexist as siblings in the same rsk/ folder; the
# scope-fields shared by their rows are what groups them.
#
# Likelihood and impact use the standard 1-5 ordinal scales; # Likelihood and impact use the standard 1-5 ordinal scales;
# severity is also 1-25 (typically L*I) and stored on each row so # severity is also 1-25 (typically L*I) and stored on each row so
# operators can override it when the simple product doesn't capture # operators can override it when the simple product doesn't capture
# the actual risk profile. # the actual risk profile.
# #
# Audit fields are server-managed and read-only (clients must not
# submit values).
#
# To customize: drop your own form.yaml into archive/<party>/rsk/ # To customize: drop your own form.yaml into archive/<party>/rsk/
# (the same directory as table.yaml). Tighten constraints with # (the same directory as table.yaml). Tighten constraints with
# `enum:`, `pattern:`, etc. Add fields and they'll appear in the # `enum:`, `pattern:`, etc. Add fields and they'll appear in the
@ -32,67 +14,18 @@
# the field in the table view too. # the field in the table view too.
title: Risk title: Risk
description: One identified risk. The first eight fields together identify the parent risk-register deliverable; `row` is this entry's position within it. Likelihood and impact use 1-5 ordinals; severity is stored separately so it can be overridden when L*I underrepresents the residual exposure. description: One identified risk. Likelihood and impact use 1-5 ordinals; severity is stored separately so it can be overridden when L*I underrepresents the residual exposure.
schema: schema:
type: object type: object
# `type` is intentionally absent from required: — the cascade's required: [id, title]
# field_defaults inject type=RSK after schema validation, and the
# form renderer surfaces it as a locked readOnly field.
required: [originator, project, discipline, sequence, title]
additionalProperties: false additionalProperties: false
properties: properties:
# --- Table-tracking components: identify which RSK deliverable id:
# this row belongs to. Together with `row`, they compose the
# row's filename via the cascade's filename_format.
originator:
type: string type: string
title: Originator title: ID
description: Organizational unit responsible for this risk register. description: Stable identifier, e.g. R-001.
minLength: 1 minLength: 1
phase:
type: string
title: Phase
description: Optional project phase code (ECI, EPC, ...).
project:
type: string
title: Project
description: Project identifier, or your corporate placeholder for non-project deliverables.
minLength: 1
area:
type: string
title: Area
description: Optional area / budget code.
discipline:
type: string
title: Discipline
description: Engineering or functional group code (EL, ME, CV, PM, ...).
minLength: 1
type:
type: string
title: Document type
description: Locked to RSK by the cascade's field_defaults; the form renders this read-only and the server returns 422 if a different value is submitted.
enum: [RSK]
sequence:
type: string
title: Sequence
description: Zero-padded integer identifying this risk register among the originator's deliverables.
minLength: 1
suffix:
type: string
title: Suffix
description: Optional structural-part suffix on the parent register.
# --- Row sequence within the table. Server-assigned on
# POST-create; preserved as-is on PUT-update.
row:
type: string
title: Row
description: Zero-padded sequence within this risk register (001, 002, ...). Server-assigned on add; do not edit.
minLength: 1
readOnly: true
# --- Risk-level data.
title: title:
type: string type: string
title: Risk title: Risk
@ -141,38 +74,6 @@ schema:
notes: notes:
type: string type: string
title: Notes title: Notes
# --- Audit fields (server-managed; read-only).
created_at:
type: string
title: Created
format: date-time
readOnly: true
created_by:
type: string
title: Created by
format: email
readOnly: true
updated_at:
type: string
title: Updated
format: date-time
readOnly: true
updated_by:
type: string
title: Updated by
format: email
readOnly: true
revision:
type: integer
title: Revision
minimum: 1
readOnly: true
previous_sha:
type: string
title: Previous SHA
description: SHA-256 (first 8 hex chars) of the prior revision's bytes.
readOnly: true
ui: ui:
description: description:
ui:widget: textarea ui:widget: textarea

View file

@ -69,40 +69,6 @@ schema:
notes: notes:
type: string type: string
title: Notes title: Notes
# --- Audit fields (server-managed; read-only). WriteWithHistory
# strips any client-supplied versions before validation and
# re-injects authoritative values on every write.
created_at:
type: string
title: Created
format: date-time
readOnly: true
created_by:
type: string
title: Created by
format: email
readOnly: true
updated_at:
type: string
title: Updated
format: date-time
readOnly: true
updated_by:
type: string
title: Updated by
format: email
readOnly: true
revision:
type: integer
title: Revision
minimum: 1
readOnly: true
previous_sha:
type: string
title: Previous SHA
description: SHA-256 (first 8 hex chars) of the prior revision's bytes.
readOnly: true
ui: ui:
scopeSummary: scopeSummary:
ui:widget: textarea ui:widget: textarea

View file

@ -409,36 +409,11 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
} }
} }
// Record files (mdl rows, rsk rows, ssr.yaml) route through
// WriteWithHistory which strips client-supplied audit fields,
// stamps server-managed ones, archives the prior version to
// <dir>/.history/<base>/, validates body fields against
// cascade-resolved field_codes, and enforces filename_format
// composition. Non-record YAML files (table.yaml, form.yaml,
// .zddc) and binary files take the plain write path below.
finalBody := body
stamped := false
if isRecordPath(abs) {
res, verrs, herr := WriteWithHistory(cfg, abs, cleanURL, body, EmailFromContext(r))
if herr != nil {
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), herr)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if len(verrs) > 0 {
writeValidationErrors(w, verrs)
auditFile(r, "put", cleanURL, http.StatusUnprocessableEntity, len(body), fmt.Errorf("validation: %d errors", len(verrs)))
return
}
finalBody = res.FinalBody
stamped = true
} else {
if err := zddc.WriteAtomic(abs, body); err != nil { if err := zddc.WriteAtomic(abs, body); err != nil {
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err) auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
}
// Invalidate ETag cache (static.go memoizes by mtime; rename produces // Invalidate ETag cache (static.go memoizes by mtime; rename produces
// a fresh mtime so a stale entry is harmless, but clearing is cheap). // a fresh mtime so a stale entry is harmless, but clearing is cheap).
etagCacheM.Delete(abs) etagCacheM.Delete(abs)
@ -446,47 +421,15 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// the sibling .converted/ dir for this source. // the sibling .converted/ dir for this source.
purgeConverted(abs) purgeConverted(abs)
etag := fileETag(finalBody) etag := fileETag(body)
w.Header().Set("ETag", `"`+etag+`"`) w.Header().Set("ETag", `"`+etag+`"`)
w.Header().Set("X-ZDDC-Source", "fileapi:put") w.Header().Set("X-ZDDC-Source", "fileapi:put")
respStatus := http.StatusCreated respStatus := http.StatusCreated
if existed { if existed {
respStatus = http.StatusOK respStatus = http.StatusOK
} }
// For record-stamped writes, echo the server-truth body so the
// tables save flow can update row.data without a re-GET. Other
// writes return no body (historical contract preserved).
if stamped {
w.Header().Set("Content-Type", "application/yaml")
w.WriteHeader(respStatus) w.WriteHeader(respStatus)
_, _ = w.Write(finalBody) auditFile(r, "put", cleanURL, respStatus, len(body), nil)
} else {
w.WriteHeader(respStatus)
}
auditFile(r, "put", cleanURL, respStatus, len(finalBody), nil)
}
// isRecordPath returns true if abs is a candidate for record-style
// handling (audit stamping + history). Excludes the well-known
// configuration filenames that share record directories: table.yaml
// (table spec), form.yaml (form schema), and .zddc (cascade
// configuration). Non-YAML extensions also fall through to the plain
// write path.
func isRecordPath(abs string) bool {
base := filepath.Base(abs)
switch base {
case "table.yaml", "form.yaml", ".zddc":
return false
}
ext := filepath.Ext(base)
if ext != ".yaml" && ext != ".yml" {
return false
}
// Exclude *.table.yaml and *.form.yaml (alternate spec naming).
if strings.HasSuffix(base, ".table.yaml") || strings.HasSuffix(base, ".form.yaml") {
return false
}
return true
} }
func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request) { func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request) {

View file

@ -89,12 +89,9 @@ type FormRequest struct {
// SubmitURL is the URL the form should POST back to (the server-injected // SubmitURL is the URL the form should POST back to (the server-injected
// "submit to my own URL" value). // "submit to my own URL" value).
SubmitURL string SubmitURL string
// Project carries the project name for create-via-ssr / // Project carries the project name for create-via-ssr requests. Empty
// create-via-rollup requests. Empty for all other kinds. // for all other kinds.
Project string Project string
// Slot carries the slot name ("mdl" or "rsk") for create-via-rollup
// requests. Empty for all other kinds.
Slot string
} }
// RecognizeFormRequest classifies r as a form-system request, or returns nil // RecognizeFormRequest classifies r as a form-system request, or returns nil
@ -133,26 +130,6 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
} }
} }
// Project-rollup MDL / RSK create: /<project>/(mdl|rsk)/form.html
// reads a `party` field from the body and routes the new row to
// <project>/archive/<party>/<slot>/. Recognized before the generic
// /<dir>/form.html branch so a virtual rollup URL doesn't get
// misrouted as an in-dir create.
if project, slot, ok := zddc.IsRollupCreateURL(urlPath); ok {
kind := "render-empty"
if method == http.MethodPost {
kind = "create-via-rollup"
}
specAbs := filepath.Join(fsRoot, project, slot, "form.yaml")
return &FormRequest{
Kind: kind,
SpecPath: specAbs,
SubmitURL: urlPath,
Project: project,
Slot: slot,
}
}
underlying := strings.TrimSuffix(urlPath, ".html") underlying := strings.TrimSuffix(urlPath, ".html")
// specEligible accepts a spec path that exists on disk OR matches // specEligible accepts a spec path that exists on disk OR matches
@ -272,8 +249,6 @@ func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *ht
serveFormUpdate(cfg, req, w, r) serveFormUpdate(cfg, req, w, r)
case "create-via-ssr": case "create-via-ssr":
serveFormCreateSSR(cfg, req, w, r) serveFormCreateSSR(cfg, req, w, r)
case "create-via-rollup":
serveFormCreateRollup(cfg, req, w, r)
default: default:
http.Error(w, "unknown form request kind", http.StatusInternalServerError) http.Error(w, "unknown form request kind", http.StatusInternalServerError)
} }
@ -330,13 +305,6 @@ func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter,
data = normalizeYAMLForJSON(data) data = normalizeYAMLForJSON(data)
} }
// Augment the schema with cascade-resolved field_codes (enum /
// pattern / labels) and any records: rule that applies in this
// folder (readOnly for locked fields, default for field_defaults).
// The augmentation is per-request and never mutates the on-disk
// spec — it's purely additive context the form renderer needs.
augmentSchemaFromCascade(spec.Schema, chain, gateDir)
ctx := formContext{ ctx := formContext{
Title: spec.Title, Title: spec.Title,
Schema: spec.Schema, Schema: spec.Schema,

View file

@ -1,706 +0,0 @@
// Package handler — history.go: orchestrates writes of "record" YAML
// files (mdl rows, rsk rows, ssr.yaml) with three guarantees the
// generic file API cannot make on its own:
//
// 1. Audit fields are server-managed. created_at / created_by /
// updated_at / updated_by / revision / previous_sha are stripped
// from incoming bodies and stamped from the request context,
// making client-side forgery impossible.
//
// 2. Prior bytes are preserved. Before the live file is overwritten
// the previous content is copied (byte-for-byte) into
// <dir>/.history/<base>/<RFC3339Nano-UTC>-<sha8>.<ext>. The
// filename embeds the timestamp + the SHA-256 prefix of the prior
// bytes — the same value that's stamped into the new record's
// previous_sha field — so the chain is auditable.
//
// 3. Filename composition is enforced. When the matched RecordRule
// declares a filename_format, the server composes the expected
// basename from body fields and rejects writes whose URL doesn't
// agree. This binds the on-disk identity to the body's
// tracking-number components, eliminating drift.
//
// Records are identified by the cascade: a .zddc records: entry
// matched against the basename selects the rule. Files that don't
// match any rule fall through to a plain write — non-record YAML
// (table.yaml, form.yaml, plain documents) is unaffected.
package handler
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"gopkg.in/yaml.v3"
)
// Audit-field key names. Snake-case to match the existing .zddc
// `created_by:` precedent rather than the camelCase used in form
// schemas (those describe domain data; these describe provenance).
const (
auditFieldCreatedAt = "created_at"
auditFieldCreatedBy = "created_by"
auditFieldUpdatedAt = "updated_at"
auditFieldUpdatedBy = "updated_by"
auditFieldRevision = "revision"
auditFieldPreviousSha = "previous_sha"
)
// historyDirName is the dot-prefixed bookkeeping folder under each
// record-containing directory. resolveTargetPath's dot-segment
// rejection means no client URL can reach into .history/ — only the
// server's own history-write code path touches it.
const historyDirName = ".history"
// WriteRecordResult carries what serveFilePut needs to surface a
// response after a successful record write.
type WriteRecordResult struct {
FinalBody []byte // bytes actually written to disk (after stamping)
Created bool // true if no prior file existed (response 201 vs 200)
}
// WriteWithHistory orchestrates a record write at abs (which must be
// the canonical on-disk path — virtual-view rewriting already
// applied). cleanURL is the URL the caller surfaces (for audit
// logging). body is the raw request bytes.
//
// Returns:
// - res, nil, nil: success; caller writes 200/201 + ETag.
// - _, errs, nil: 422 with the validation errors (locked
// mismatch, field_code violation, filename
// composition mismatch).
// - _, _, err: internal error; caller writes 500.
//
// The function does NOT do ACL, ETag-precondition, or canonical-
// ancestor seeding — those are still serveFilePut's job and run
// before this call. The function DOES handle prior-bytes capture,
// audit stamping, history write, and live write.
func WriteWithHistory(cfg config.Config, abs, cleanURL string, body []byte, principalEmail string) (WriteRecordResult, []jsonschema.Error, error) {
dir := filepath.Dir(abs)
base := filepath.Base(abs)
// Resolve cascade at the record's parent dir.
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
if err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("effective policy: %w", err)
}
_, rule, hasRule := chain.EffectiveRecordRule(base)
// Read prior bytes (nil if create).
var priorBody []byte
priorExisted := false
if data, err := os.ReadFile(abs); err == nil {
priorBody = data
priorExisted = true
} else if !errors.Is(err, os.ErrNotExist) {
return WriteRecordResult{}, nil, fmt.Errorf("read prior: %w", err)
}
// Parse incoming body as a YAML map. Empty body is allowed
// (the schema validator catches required-field omissions, or
// the caller-side spec is permissive); we use an empty map.
bodyMap := map[string]any{}
if len(body) > 0 {
if err := yaml.Unmarshal(body, &bodyMap); err != nil {
return WriteRecordResult{}, []jsonschema.Error{{Path: "/", Message: "body is not valid YAML: " + err.Error()}}, nil
}
if bodyMap == nil {
bodyMap = map[string]any{}
}
}
// Strip client-supplied audit fields. The server is the sole
// authority for these; any value we'd accept here is forgeable.
stripAuditFields(bodyMap)
// Honor records: rule. If no rule matched the basename, fall
// through to a plain write (no stamping, no history) — this
// covers non-record YAML files like table.yaml that may share
// a directory with records.
if !hasRule {
if err := zddc.WriteAtomic(abs, body); err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("write: %w", err)
}
return WriteRecordResult{FinalBody: body, Created: !priorExisted}, nil, nil
}
codes := chain.EffectiveFieldCodes()
// Inject field_defaults for keys the body omitted (so the
// stamped result is self-describing) and check locked: against
// any conflicting client values.
var verrs []jsonschema.Error
for k, want := range rule.FieldDefaults {
got, present := bodyMap[k]
if !present {
bodyMap[k] = want
continue
}
if locked := containsString(rule.Locked, k); locked {
gotStr := asString(got)
if gotStr != want {
verrs = append(verrs, jsonschema.Error{
Path: "/" + k,
Message: fmt.Sprintf("field is locked to %q in this folder; got %q", want, gotStr),
})
}
}
}
// Validate body values against field_codes (best-effort: only
// fields actually present in the body are checked; absent
// fields are someone else's concern — typically the form
// schema's required: list).
for k, code := range codes {
raw, ok := bodyMap[k]
if !ok {
continue
}
s := asString(raw)
if s == "" {
continue // empty/optional — schema enforces presence
}
if err := code.Validate(s); err != nil {
verrs = append(verrs, jsonschema.Error{
Path: "/" + k,
Message: err.Error(),
})
}
}
if len(verrs) > 0 {
return WriteRecordResult{}, verrs, nil
}
// Compose filename from body when filename_format is set, and
// verify the URL basename matches. Skipped when the rule has
// no format (SSR: identity is the parent folder name).
if rule.FilenameFormat != "" {
composed, cerr := composeFilename(rule.FilenameFormat, bodyMap)
if cerr != nil {
return WriteRecordResult{}, []jsonschema.Error{{Path: "/", Message: cerr.Error()}}, nil
}
ext := filepath.Ext(base)
composedWithExt := composed + ext
if composedWithExt != base {
return WriteRecordResult{}, []jsonschema.Error{{
Path: "/",
Message: fmt.Sprintf("filename mismatch: URL is %q, body composes to %q", base, composedWithExt),
}}, nil
}
}
// Stamp audit fields. On create: created_* and updated_* are
// both the current principal/timestamp; revision = 1. On
// update: preserve created_* (parse from priorBody), refresh
// updated_*, increment revision, set previous_sha = sha-prefix
// of priorBody.
now := time.Now().UTC().Format(time.RFC3339Nano)
if principalEmail == "" {
principalEmail = "anonymous"
}
priorAudit := parsePriorAudit(priorBody)
if !priorExisted {
bodyMap[auditFieldCreatedAt] = now
bodyMap[auditFieldCreatedBy] = principalEmail
bodyMap[auditFieldRevision] = 1
} else {
if priorAudit.createdAt != "" {
bodyMap[auditFieldCreatedAt] = priorAudit.createdAt
} else {
// Lazy migration: the prior file had no created_*
// stamp. Treat this write as the establishment of
// audit history — created and updated are the same
// principal/timestamp (we don't know who originally
// authored it).
bodyMap[auditFieldCreatedAt] = now
}
if priorAudit.createdBy != "" {
bodyMap[auditFieldCreatedBy] = priorAudit.createdBy
} else {
bodyMap[auditFieldCreatedBy] = principalEmail
}
bodyMap[auditFieldRevision] = priorAudit.revision + 1
bodyMap[auditFieldPreviousSha] = sha8(priorBody)
}
bodyMap[auditFieldUpdatedAt] = now
bodyMap[auditFieldUpdatedBy] = principalEmail
finalBody, err := yaml.Marshal(bodyMap)
if err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("marshal: %w", err)
}
// Write history BEFORE live. If we crash after history but
// before live, the prior version is safe (still on disk under
// its history filename). The reverse order would lose the
// prior bytes if the live write succeeded but history failed.
// On a clean retry, the history filename is deterministic
// (timestamp+sha8 of priorBody) — rewriting it idempotently
// is harmless when the live write later succeeds.
if priorExisted {
histDir := filepath.Join(dir, historyDirName, stripExt(base))
if err := os.MkdirAll(histDir, 0o755); err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("mkdir history: %w", err)
}
histName := now + "-" + sha8(priorBody) + filepath.Ext(base)
histPath := filepath.Join(histDir, histName)
if err := zddc.WriteAtomic(histPath, priorBody); err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("write history: %w", err)
}
}
if err := zddc.WriteAtomic(abs, finalBody); err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("write live: %w", err)
}
return WriteRecordResult{FinalBody: finalBody, Created: !priorExisted}, nil, nil
}
// priorAuditSnapshot is the minimum we need from a prior version's
// body to stamp the next revision: who created it (preserved
// forever) and what revision number it carried (so we can ++).
type priorAuditSnapshot struct {
createdAt string
createdBy string
revision int
}
func parsePriorAudit(body []byte) priorAuditSnapshot {
if len(body) == 0 {
return priorAuditSnapshot{}
}
m := map[string]any{}
if err := yaml.Unmarshal(body, &m); err != nil {
return priorAuditSnapshot{}
}
out := priorAuditSnapshot{}
if v, ok := m[auditFieldCreatedAt].(string); ok {
out.createdAt = v
}
if v, ok := m[auditFieldCreatedBy].(string); ok {
out.createdBy = v
}
switch v := m[auditFieldRevision].(type) {
case int:
out.revision = v
case int64:
out.revision = int(v)
case float64:
out.revision = int(v)
}
return out
}
func stripAuditFields(m map[string]any) {
delete(m, auditFieldCreatedAt)
delete(m, auditFieldCreatedBy)
delete(m, auditFieldUpdatedAt)
delete(m, auditFieldUpdatedBy)
delete(m, auditFieldRevision)
delete(m, auditFieldPreviousSha)
}
// composeFilename interpolates a filename_format template against
// the supplied body fields. Placeholders are {fieldname} for
// required segments and {fieldname?} for optional ones; an optional
// placeholder with an empty/missing body field is dropped along
// with one adjacent separator if both neighbors are static text.
//
// Example, format = "{originator}-{phase?}-{project}-{type}-{sequence}{suffix?}"
// with body = {originator: ACM, project: PRJ, type: SPC, sequence: 0001}
// (phase + suffix absent) yields "ACM-PRJ-SPC-0001".
//
// Adjacent-separator handling: the function recognises a "-" or "_"
// literal immediately preceding an optional placeholder and drops
// it together with the placeholder when the field is empty. Static
// text not adjacent to a placeholder is preserved as-is. A literal
// "{" or "}" must be escaped as "{{" / "}}" (currently unused —
// the embedded defaults don't need it).
func composeFilename(format string, body map[string]any) (string, error) {
var out strings.Builder
out.Grow(len(format))
i := 0
for i < len(format) {
c := format[i]
// Literal { or } escapes: {{ → {, }} → }.
if c == '{' && i+1 < len(format) && format[i+1] == '{' {
out.WriteByte('{')
i += 2
continue
}
if c == '}' && i+1 < len(format) && format[i+1] == '}' {
out.WriteByte('}')
i += 2
continue
}
if c != '{' {
out.WriteByte(c)
i++
continue
}
// Placeholder: scan to '}'.
end := strings.IndexByte(format[i+1:], '}')
if end == -1 {
return "", fmt.Errorf("filename_format: unterminated placeholder at offset %d", i)
}
name := format[i+1 : i+1+end]
i += end + 2 // past the '}'
optional := false
if strings.HasSuffix(name, "?") {
optional = true
name = name[:len(name)-1]
}
val := asString(body[name])
if val == "" {
if !optional {
return "", fmt.Errorf("filename_format: required field %q is missing or empty", name)
}
// Drop the trailing separator we just wrote, if any.
// For "A-{b?}-C" with b empty we want "A-C": dropping
// the preceding '-' here, then letting the next
// iteration emit the trailing '-' from the format, is
// exactly one connector between A and C. (Earlier
// versions of this code also skipped the leading
// separator, which double-elided.)
s := out.String()
if n := len(s); n > 0 && (s[n-1] == '-' || s[n-1] == '_') {
out.Reset()
out.WriteString(s[:n-1])
}
continue
}
out.WriteString(val)
}
return out.String(), nil
}
// AssignNextRow finds the next free row sequence within the
// row-scope group identified by scopeFields. Used by POST-create
// handlers (rsk row creation) before invoking WriteWithHistory.
// Returns the zero-padded string value to inject into bodyMap[rowField].
//
// Width is fixed at 3 (covers up to 999 rows per table). Operators
// who need more declare a per-deployment field_codes:row pattern;
// the width here is for the auto-assign output, not for parsing
// (which uses the matched pattern from the cascade).
func AssignNextRow(dir, rowField string, scopeFields []string, body map[string]any) (string, error) {
if rowField == "" {
return "", fmt.Errorf("row_field is empty")
}
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "001", nil
}
return "", err
}
max := 0
rowRe := regexp.MustCompile(`^[0-9]+$`)
for _, ent := range entries {
if ent.IsDir() {
continue
}
name := ent.Name()
if !strings.HasSuffix(name, filepath.Ext(name)) {
continue
}
path := filepath.Join(dir, name)
data, err := os.ReadFile(path)
if err != nil {
continue
}
other := map[string]any{}
if err := yaml.Unmarshal(data, &other); err != nil {
continue
}
// Same scope group?
sameGroup := true
for _, f := range scopeFields {
if asString(other[f]) != asString(body[f]) {
sameGroup = false
break
}
}
if !sameGroup {
continue
}
v := asString(other[rowField])
if !rowRe.MatchString(v) {
continue
}
n := atoiSafe(v)
if n > max {
max = n
}
}
return fmt.Sprintf("%03d", max+1), nil
}
// ServeHistoryList responds to GET <record>.yaml?history=1 with the
// list of prior revisions archived under .history/<base>/. The
// caller has already evaluated ACL against the live record (read
// permission on the parent dir gates history visibility too — if
// you can read the current state you can read its history).
//
// Returns 404 when abs doesn't exist or isn't a record (the caller
// should rely on the live record's GET 404 path instead of leaking
// existence here, but defense in depth costs nothing).
func ServeHistoryList(w http.ResponseWriter, r *http.Request, abs string) {
if !isRecordPathForHistory(abs) {
http.NotFound(w, r)
return
}
if _, err := os.Stat(abs); err != nil {
if errors.Is(err, os.ErrNotExist) {
// Record file gone; the caller's normal 404 path
// suppresses existence-leak, so we mirror that.
http.NotFound(w, r)
return
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
entries, err := ListHistory(abs)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "history-list")
// json.NewEncoder for streaming; sort already happened in
// ListHistory.
enc := json.NewEncoder(w)
if err := enc.Encode(entries); err != nil {
// Body already partially flushed at this point; nothing to do.
return
}
}
// isRecordPathForHistory mirrors isRecordPath but lives in this file
// so the history-list handler doesn't need to import its caller's
// internal helper. Keep the two in sync — if one accepts a new
// extension the other should too.
func isRecordPathForHistory(abs string) bool {
base := filepath.Base(abs)
switch base {
case "table.yaml", "form.yaml", ".zddc":
return false
}
ext := filepath.Ext(base)
if ext != ".yaml" && ext != ".yml" {
return false
}
if strings.HasSuffix(base, ".table.yaml") || strings.HasSuffix(base, ".form.yaml") {
return false
}
return true
}
// HistoryEntry describes one prior revision of a record, as listed
// by ServeHistoryList.
type HistoryEntry struct {
Revision int `json:"revision"`
Ts string `json:"ts"`
By string `json:"by"`
Sha8 string `json:"sha"`
Path string `json:"path"`
}
// ListHistory walks the .history/<base>/ directory adjacent to abs
// and returns one HistoryEntry per archived revision, sorted newest
// first. Empty list when the dir doesn't exist (e.g. record never
// updated).
//
// Filename format: <RFC3339Nano>-<sha8>.<ext>. Author/revision are
// read from the YAML body's audit fields — those describe the
// archived bytes' provenance.
func ListHistory(abs string) ([]HistoryEntry, error) {
dir := filepath.Dir(abs)
base := filepath.Base(abs)
histDir := filepath.Join(dir, historyDirName, stripExt(base))
ents, err := os.ReadDir(histDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
out := make([]HistoryEntry, 0, len(ents))
for _, e := range ents {
if e.IsDir() {
continue
}
name := e.Name()
// Expected shape: <ts>-<sha8>.<ext>. Parse from the right
// to be lenient about timestamps that contain '-'.
ext := filepath.Ext(name)
stem := strings.TrimSuffix(name, ext)
idx := strings.LastIndexByte(stem, '-')
if idx < 0 {
continue
}
ts := stem[:idx]
sha := stem[idx+1:]
entry := HistoryEntry{Ts: ts, Sha8: sha, Path: filepath.Join(historyDirName, stripExt(base), name)}
// Pull author + revision from the archived body.
if data, err := os.ReadFile(filepath.Join(histDir, name)); err == nil {
snap := parsePriorAudit(data)
entry.Revision = snap.revision
entry.By = snap.createdBy
// updated_by is more informative when present.
m := map[string]any{}
if err := yaml.Unmarshal(data, &m); err == nil {
if v, ok := m[auditFieldUpdatedBy].(string); ok && v != "" {
entry.By = v
}
}
}
out = append(out, entry)
}
sort.Slice(out, func(i, j int) bool { return out[i].Ts > out[j].Ts })
return out, nil
}
// augmentSchemaFromCascade mutates schema in place to inject
// cascade-resolved field_codes and records:-rule constraints. For
// every property whose name matches a field-code key, the relevant
// enum/pattern/labels are injected. For every record-rule's locked
// field, the corresponding property is marked readOnly. For every
// field_default, the corresponding property's Default is set if
// absent.
//
// gateDir is the directory the cascade was resolved at — needed
// only to pick the right records: rule when multiple patterns
// could match. The current cascade interface gives us the chain
// already; we pull a single "*.yaml" representative rule (matching
// the create-time behaviour in serveFormCreateRollup).
//
// Mutates the input schema. No-op when schema is nil.
func augmentSchemaFromCascade(schema *jsonschema.Schema, chain zddc.PolicyChain, gateDir string) {
if schema == nil || schema.Properties == nil {
return
}
codes := chain.EffectiveFieldCodes()
for name, prop := range schema.Properties {
if code, ok := codes[name]; ok {
switch code.Kind {
case zddc.FieldCodeEnum:
// Populate Enum with the code keys (sorted for
// deterministic order). Labels carries the
// human-readable display strings.
keys := make([]string, 0, len(code.Codes))
for k := range code.Codes {
keys = append(keys, k)
}
sort.Strings(keys)
if len(prop.Enum) == 0 {
prop.Enum = make([]any, len(keys))
for i, k := range keys {
prop.Enum[i] = k
}
}
if prop.Labels == nil && len(code.Codes) > 0 {
prop.Labels = make(map[string]string, len(code.Codes))
for k, v := range code.Codes {
prop.Labels[k] = v
}
}
case zddc.FieldCodePattern:
if prop.Pattern == "" {
prop.Pattern = code.Pattern
}
case zddc.FieldCodeFree:
// No constraint to inject; description is the
// only field and the operator can author it
// directly in the form spec.
}
}
}
// Apply the matched records:-rule's readOnly + default to
// matching properties. We probe with "*.yaml" — the records
// entries shipped in the embedded defaults all match that
// glob; operator schemas with literal-keyed rules would still
// be honoured by serveFormCreateRollup but won't be reflected
// in the form-render augmentation here.
if _, rule, ok := chain.EffectiveRecordRule("placeholder.yaml"); ok {
for _, name := range rule.Locked {
if prop, present := schema.Properties[name]; present {
prop.ReadOnly = true
}
}
for name, val := range rule.FieldDefaults {
if prop, present := schema.Properties[name]; present {
if prop.Default == nil {
prop.Default = val
}
}
}
}
}
// ---- small helpers ----
func sha8(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])[:8]
}
func stripExt(name string) string {
return strings.TrimSuffix(name, filepath.Ext(name))
}
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
func asString(v any) string {
switch s := v.(type) {
case string:
return s
case nil:
return ""
case int:
return fmt.Sprintf("%d", s)
case int64:
return fmt.Sprintf("%d", s)
case float64:
// Strip trailing .0 for the common integer-in-JSON case.
if s == float64(int64(s)) {
return fmt.Sprintf("%d", int64(s))
}
return fmt.Sprintf("%v", s)
default:
return fmt.Sprintf("%v", s)
}
}
func atoiSafe(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
}
return n
}

View file

@ -1,391 +0,0 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"gopkg.in/yaml.v3"
)
// historyTestSetup wires a fresh root with the embedded defaults
// (which declare the records: rules for mdl/rsk/ssr) plus a
// permissive ACL for *@example.com so the test cohort can write
// anywhere under archive/.
//
// Returns (cfg, do) where do invokes ServeFileAPI directly — we
// bypass the dispatch tree because the system-under-test is the
// serveFilePut path, not the entire HTTP stack.
func historyTestSetup(t *testing.T) (config.Config, func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder) {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 0o644); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
do := func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/yaml")
} else {
req = httptest.NewRequest(method, target, nil)
}
for k, v := range headers {
req.Header.Set(k, v)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
return rec
}
return cfg, do
}
// TestRecordPut_CreateStampsAuditFields verifies that a PUT to a
// fresh mdl row inserts created_*, updated_*, revision=1 server-side,
// and that the response body echoes the stamped YAML.
func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
cfg, do := historyTestSetup(t)
// Build a body with the right components for the embedded
// mdl rule's filename_format.
body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Test spec\n")
url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml"
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
if rec.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
// Response body should be the stamped YAML.
out := map[string]any{}
if err := yaml.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("parse response body: %v", err)
}
if out["created_by"] != "alice@example.com" {
t.Errorf("created_by=%v want alice@example.com", out["created_by"])
}
if out["updated_by"] != "alice@example.com" {
t.Errorf("updated_by=%v want alice@example.com", out["updated_by"])
}
if out["revision"] != 1 {
t.Errorf("revision=%v want 1", out["revision"])
}
if out["created_at"] == "" || out["updated_at"] == "" {
t.Errorf("created_at/updated_at empty: %+v", out)
}
if _, hasPrev := out["previous_sha"]; hasPrev {
t.Errorf("previous_sha should be absent on create: %+v", out)
}
// On-disk file matches the response body.
abs := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", "ACM-PRJ-EL-SPC-0001.yaml")
disk, err := os.ReadFile(abs)
if err != nil {
t.Fatalf("read disk: %v", err)
}
if !bytes.Equal(disk, rec.Body.Bytes()) {
t.Errorf("response body != disk bytes\nresponse=%s\ndisk=%s", rec.Body.String(), disk)
}
// No history dir yet (create only).
histDir := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", ".history")
if _, err := os.Stat(histDir); !os.IsNotExist(err) {
t.Errorf(".history/ should not exist after create-only; got err=%v", err)
}
}
// TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior verifies
// that the second write captures the first into .history/<base>/,
// chains previous_sha, and increments revision.
func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
cfg, do := historyTestSetup(t)
url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml"
body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n")
rec := do(http.MethodPut, url, "alice@example.com", body1, nil)
if rec.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
}
firstEtag := strings.Trim(rec.Result().Header.Get("ETag"), `"`)
body2 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V2\n")
rec = do(http.MethodPut, url, "bob@example.com", body2, map[string]string{
"If-Match": `"` + firstEtag + `"`,
})
if rec.Code != http.StatusOK {
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
}
out := map[string]any{}
if err := yaml.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("parse update response: %v", err)
}
if out["created_by"] != "alice@example.com" {
t.Errorf("created_by should be preserved as alice: %v", out["created_by"])
}
if out["updated_by"] != "bob@example.com" {
t.Errorf("updated_by should be bob: %v", out["updated_by"])
}
if out["revision"] != 2 {
t.Errorf("revision=%v want 2", out["revision"])
}
if out["previous_sha"] == "" || out["previous_sha"] == nil {
t.Errorf("previous_sha should be non-empty on update: %+v", out)
}
// .history/ACM-PRJ-EL-SPC-0001/ has exactly one entry (the v1 bytes).
histDir := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", ".history", "ACM-PRJ-EL-SPC-0001")
ents, err := os.ReadDir(histDir)
if err != nil {
t.Fatalf("read history dir: %v", err)
}
if len(ents) != 1 {
t.Fatalf("history entries=%d want 1", len(ents))
}
// The archived file's title is V1 (the prior version).
prior, err := os.ReadFile(filepath.Join(histDir, ents[0].Name()))
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(prior, []byte("title: V1")) {
t.Errorf("archived prior version missing title=V1; got %s", prior)
}
}
// TestRecordPut_ConflictPreservesHistory ensures a 412 doesn't
// write anything — no history entry, no overwrite.
func TestRecordPut_ConflictPreservesHistory(t *testing.T) {
cfg, do := historyTestSetup(t)
url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml"
body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n")
if rec := do(http.MethodPut, url, "alice@example.com", body1, nil); rec.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
}
body2 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V2\n")
rec := do(http.MethodPut, url, "bob@example.com", body2, map[string]string{
"If-Match": `"deadbeefdeadbeefdeadbeefdeadbeef"`, // wrong
})
if rec.Code != http.StatusPreconditionFailed {
t.Fatalf("expected 412, got %d body=%s", rec.Code, rec.Body.String())
}
histDir := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", ".history")
if _, err := os.Stat(histDir); !os.IsNotExist(err) {
t.Errorf("history dir should not exist after 412 conflict; got err=%v", err)
}
}
// TestRecordPut_ClientAuditFieldsStripped: client tries to forge
// audit fields → server silently strips and overwrites them.
func TestRecordPut_ClientAuditFieldsStripped(t *testing.T) {
_, do := historyTestSetup(t)
url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml"
body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Forged\n" +
"created_by: eve@evil.com\nupdated_by: eve@evil.com\nrevision: 999\n")
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
if rec.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
out := map[string]any{}
if err := yaml.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out["created_by"] != "alice@example.com" {
t.Errorf("client-forged created_by leaked through: %v", out["created_by"])
}
if out["revision"] != 1 {
t.Errorf("client-forged revision leaked through: %v", out["revision"])
}
}
// TestRecordPut_FilenameMismatch: body fields compose to a different
// tracking number than the URL → 422 with a "/" path error.
func TestRecordPut_FilenameMismatch(t *testing.T) {
_, do := historyTestSetup(t)
// URL claims sequence=0002 but body says 0001 → mismatch.
url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0002.yaml"
body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: X\n")
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d body=%s", rec.Code, rec.Body.String())
}
}
// TestRecordPut_LockedFieldRejected: rsk rule locks type=RSK; a
// client submitting type=SPC for an rsk row gets 422 with
// path=/type.
func TestRecordPut_LockedFieldRejected(t *testing.T) {
_, do := historyTestSetup(t)
url := "/Project/archive/Acme/rsk/ACM-PRJ-EL-RSK-0001-001.yaml"
// Client tries type=SPC even though rsk/ locks type=RSK.
body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\nrow: '001'\ntitle: X\n")
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d body=%s", rec.Code, rec.Body.String())
}
var errs struct {
Errors []struct {
Path string `json:"path"`
Message string `json:"message"`
} `json:"errors"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &errs)
found := false
for _, e := range errs.Errors {
if e.Path == "/type" {
found = true
}
}
if !found {
t.Errorf("expected /type lock error; got %v", errs.Errors)
}
}
// TestRecordPut_SSRHistoryAtPartyLevel: writing to an SSR row's
// canonical archive/<party>/ssr.yaml puts history at
// archive/<party>/.history/ssr/, NOT at archive/.history/<party>/.
func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) {
cfg, do := historyTestSetup(t)
// We bypass the SSR create handler and just PUT directly to the
// canonical path the SSR rewrites would land on.
abs := filepath.Join(cfg.Root, "Project", "archive", "0330C1")
if err := os.MkdirAll(abs, 0o755); err != nil {
t.Fatal(err)
}
// The plain file API uses the bytes as-is; ssr.yaml's records:
// rule will trigger audit stamping but no filename composition
// (no filename_format on the SSR records: entry).
url := "/Project/archive/0330C1/ssr.yaml"
body := []byte("kind: SSR\nvendorType: subcontractor\ncontractNo: PO-001\nscopeSummary: Concrete\n")
if rec := do(http.MethodPut, url, "alice@example.com", body, nil); rec.Code != http.StatusCreated {
t.Fatalf("first put status=%d body=%s", rec.Code, rec.Body.String())
}
// Read back current ETag so we can update with If-Match.
getRec := do(http.MethodGet, url, "alice@example.com", nil, nil)
_ = getRec // ETag rebuilt from disk by fileETag inside serveFilePut
rec := do(http.MethodPut, url, "bob@example.com", []byte("kind: SSR\nvendorType: supplier\ncontractNo: PO-002\nscopeSummary: Pipe\n"), nil)
if rec.Code != http.StatusOK {
t.Fatalf("second put status=%d body=%s", rec.Code, rec.Body.String())
}
// History at archive/0330C1/.history/ssr/, NOT at archive/.history/.
wanted := filepath.Join(cfg.Root, "Project", "archive", "0330C1", ".history", "ssr")
if _, err := os.Stat(wanted); err != nil {
t.Fatalf("expected history at %s; err=%v", wanted, err)
}
bad := filepath.Join(cfg.Root, "Project", "archive", ".history")
if _, err := os.Stat(bad); !os.IsNotExist(err) {
t.Errorf("history must NOT live at %s; err=%v", bad, err)
}
}
// TestRollupCreate_AssignsRowAndComposesFilename: posting an rsk
// row via the rollup create endpoint causes the server to compute
// the filename from the body components AND auto-assign the next
// row number within the table-scope group.
func TestRollupCreate_AssignsRowAndComposesFilename(t *testing.T) {
cfg, _ := historyTestSetup(t)
// Materialize the party folder (rollup create requires it).
partyAbs := filepath.Join(cfg.Root, "Project", "archive", "0330C1")
if err := os.MkdirAll(partyAbs, 0o755); err != nil {
t.Fatal(err)
}
// First row: full table-tracking components + the routing party
// field. Server should pick row=001.
body1 := `{"party":"0330C1","originator":"ACM","project":"PRJ","discipline":"EL","sequence":"0001","title":"Schedule slip"}`
rec := doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body1)
if rec.Code != http.StatusCreated {
t.Fatalf("first rsk create status=%d body=%s", rec.Code, rec.Body.String())
}
loc := rec.Result().Header.Get("Location")
if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0001-001.yaml") {
t.Errorf("first row location=%q want ...-RSK-0001-001.yaml", loc)
}
// Second row in the same table: row=002.
body2 := `{"party":"0330C1","originator":"ACM","project":"PRJ","discipline":"EL","sequence":"0001","title":"Cost overrun"}`
rec = doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body2)
if rec.Code != http.StatusCreated {
t.Fatalf("second rsk create status=%d body=%s", rec.Code, rec.Body.String())
}
loc = rec.Result().Header.Get("Location")
if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0001-002.yaml") {
t.Errorf("second row location=%q want ...-RSK-0001-002.yaml", loc)
}
// Different table-scope (sequence=0002) restarts at row=001.
body3 := `{"party":"0330C1","originator":"ACM","project":"PRJ","discipline":"EL","sequence":"0002","title":"Risk in second register"}`
rec = doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body3)
if rec.Code != http.StatusCreated {
t.Fatalf("third rsk create status=%d body=%s", rec.Code, rec.Body.String())
}
loc = rec.Result().Header.Get("Location")
if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0002-001.yaml") {
t.Errorf("third row (new scope) location=%q want ...-RSK-0002-001.yaml", loc)
}
// All three files contain audit fields (proves WriteWithHistory ran).
rskDir := filepath.Join(partyAbs, "rsk")
ents, err := os.ReadDir(rskDir)
if err != nil {
t.Fatal(err)
}
yamlCount := 0
for _, e := range ents {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
continue
}
yamlCount++
data, err := os.ReadFile(filepath.Join(rskDir, e.Name()))
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(data, []byte("created_by: alice@example.com")) {
t.Errorf("%s missing created_by stamp: %s", e.Name(), data)
}
}
if yamlCount != 3 {
t.Errorf("expected 3 rsk row files; got %d", yamlCount)
}
}
// doForm is a small helper that dispatches a form POST through
// RecognizeFormRequest → ServeForm (the rollup/SSR create entry
// point). Lets the history tests share the same harness without
// pulling in the full ssrTestSetup helper.
func doForm(t *testing.T, cfg config.Config, method, target, email, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
formReq := RecognizeFormRequest(cfg.Root, method, target)
if formReq == nil {
t.Fatalf("RecognizeFormRequest returned nil for %s %s", method, target)
}
ServeForm(cfg, formReq, rec, req)
return rec
}

View file

@ -30,7 +30,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema" "codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
@ -166,216 +165,18 @@ func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWrit
http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError) http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
return return
} }
// Route through WriteWithHistory so audit fields (created_*, if err := zddc.WriteAtomic(yamlAbs, yamlBytes); err != nil {
// updated_*, revision=1) are stamped uniformly with the PUT auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, len(yamlBytes), err)
// path. No prior file exists, so the history-write branch is a http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
// no-op — only the stamping + live write fire.
res, verrs, herr := WriteWithHistory(cfg, yamlAbs, rowURL, yamlBytes, email)
if herr != nil {
auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, len(yamlBytes), herr)
http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError)
return return
} }
if len(verrs) > 0 {
writeValidationErrors(w, verrs)
return
}
finalBody := res.FinalBody
w.Header().Set("Location", rowURL) w.Header().Set("Location", rowURL)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "ssr-create") w.Header().Set("X-ZDDC-Source", "ssr-create")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL))) _, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL)))
auditFile(r, "ssr-create", rowURL, http.StatusCreated, len(finalBody), nil) auditFile(r, "ssr-create", rowURL, http.StatusCreated, len(yamlBytes), nil)
}
// serveFormCreateRollup adds a row to a project-level MDL or RSK
// rollup view by writing it inside the per-party folder named by the
// submitted `party` field.
//
// Wire-form: POST /<project>/(mdl|rsk)/form.html
//
// Content-Type: application/yaml | application/json
// body: { party: "<name>", ...row fields... }
//
// The rollup form schema (default-project-{mdl,rsk}.form.yaml) makes
// `party` a required field; the rollup view's `Package` column maps
// to it. The party folder must already exist — create it via the
// SSR view first. ACL gate: ActionCreate at
// <project>/archive/<party>/<slot>/, same chain the generic
// serveFormCreate would gate against if the user were on the
// per-party path directly.
//
// On success: 201 + Location: /<project>/<slot>/<party>__<filename>.yaml,
// the virtual row URL that the rollup table client uses to address
// the new row.
func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if email == "" {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
if req.Project == "" || (req.Slot != "mdl" && req.Slot != "rsk") {
http.Error(w, "internal: rollup create missing project/slot", http.StatusInternalServerError)
return
}
data, err := decodeRequestData(r)
if err != nil {
http.Error(w, "request body: "+err.Error(), http.StatusBadRequest)
return
}
spec, err := loadFormSpec(cfg.Root, req.SpecPath)
if err != nil {
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
return
}
if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 {
writeValidationErrors(w, errs)
return
}
dataMap, ok := data.(map[string]interface{})
if !ok {
http.Error(w, "request body must be a YAML/JSON object", http.StatusBadRequest)
return
}
partyRaw, _ := dataMap["party"].(string)
party := strings.TrimSpace(partyRaw)
if !zddc.ValidPartyName(party) {
writeValidationErrors(w, []jsonschema.Error{{
Path: "/party",
Message: "must match " + `^[A-Za-z0-9][A-Za-z0-9.-]*$`,
}})
return
}
partyAbs := filepath.Join(cfg.Root, req.Project, "archive", party)
if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
if info, err := os.Stat(partyAbs); err != nil || !info.IsDir() {
writeValidationErrors(w, []jsonschema.Error{{
Path: "/party",
Message: "party folder does not exist — create it via the SSR view first",
}})
return
}
slotAbs := filepath.Join(partyAbs, req.Slot)
slotURL := "/" + req.Project + "/archive/" + party + "/" + req.Slot + "/"
rowDirURL := slotURL // The slot folder where the new row lands.
_ = rowDirURL // kept for clarity; ACL chain is gated below.
// ACL gate: create at <project>/archive/<party>/<slot>/. authorizeAction
// walks up to the closest existing ancestor (typically <party>/), where
// the auto-own .zddc grants the party owner rwcd.
if !authorizeAction(cfg, w, r, slotAbs, slotURL, policy.ActionCreate) {
return
}
// Strip the routing key from the data before write — the folder
// name IS the identity and the per-party MDL/RSK schemas forbid
// `additionalProperties` other than the listed ones.
delete(dataMap, "party")
if err := os.MkdirAll(slotAbs, 0o755); err != nil {
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Resolve the cascade rule at slotAbs to pick a composed filename.
// The defaults.zddc.yaml records: entries declare a "*.yaml" rule
// for both mdl/ and rsk/ folders with filename_format pointing at
// body fields; for RSK, the rule also carries row_field +
// row_scope_fields so the server can assign the next row sequence
// within the table-tracking group.
chain, err := zddc.EffectivePolicy(cfg.Root, slotAbs)
if err != nil {
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err)
http.Error(w, "cascade resolve: "+err.Error(), http.StatusInternalServerError)
return
}
// Probe with the wildcard placeholder; the embedded defaults
// declare a "*.yaml" entry for both slots.
_, rule, hasRule := chain.EffectiveRecordRule("placeholder.yaml")
var target, fname string
if hasRule && rule.FilenameFormat != "" {
// Apply field_defaults (e.g. type=RSK for rsk/).
for k, want := range rule.FieldDefaults {
if _, present := dataMap[k]; !present {
dataMap[k] = want
}
}
// Auto-assign the per-row sequence for RSK-style rules.
if rule.RowField != "" {
rowVal, rerr := AssignNextRow(slotAbs, rule.RowField, rule.RowScopeFields, dataMap)
if rerr != nil {
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, rerr)
http.Error(w, "row assign: "+rerr.Error(), http.StatusInternalServerError)
return
}
dataMap[rule.RowField] = rowVal
}
composed, cerr := composeFilename(rule.FilenameFormat, dataMap)
if cerr != nil {
writeValidationErrors(w, []jsonschema.Error{{Path: "/", Message: cerr.Error()}})
return
}
fname = composed + ".yaml"
target = filepath.Join(slotAbs, fname)
if _, err := os.Stat(target); err == nil {
http.Error(w, "Conflict — a row with this composed tracking number already exists", http.StatusConflict)
return
}
} else {
// Fallback for deployments that override the embedded
// defaults without providing records: entries — keep the
// historical date+email naming so they aren't broken by
// this upgrade.
dateStr := time.Now().UTC().Format("2006-01-02")
emailSan := sanitizeEmail(email)
base := dateStr + "-" + emailSan
var ok bool
target, fname, ok = pickAvailableFilename(slotAbs, base, ".yaml")
if !ok {
http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict)
return
}
}
yamlBytes, err := yaml.Marshal(dataMap)
if err != nil {
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err)
http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
return
}
// Route through WriteWithHistory for audit stamping. The
// filename_format check inside WriteWithHistory passes because
// the path we constructed above used the same composition.
res, verrs, herr := WriteWithHistory(cfg, target, "/"+req.Project+"/"+req.Slot+"/"+party+"__"+fname, yamlBytes, email)
if herr != nil {
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, len(yamlBytes), herr)
http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError)
return
}
if len(verrs) > 0 {
writeValidationErrors(w, verrs)
return
}
finalBody := res.FinalBody
rowURL := "/" + req.Project + "/" + req.Slot + "/" + party + "__" + fname
w.Header().Set("Location", rowURL)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "rollup-create")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL)))
auditFile(r, "rollup-create", rowURL, http.StatusCreated, len(finalBody), nil)
} }
// serveSSRRename renames a party folder by rewriting an SSR row URL. // serveSSRRename renames a party folder by rewriting an SSR row URL.

View file

@ -65,12 +65,6 @@ var embeddedDefaultProjectMdlTable []byte
//go:embed default-project-rsk.table.yaml //go:embed default-project-rsk.table.yaml
var embeddedDefaultProjectRskTable []byte var embeddedDefaultProjectRskTable []byte
//go:embed default-project-mdl.form.yaml
var embeddedDefaultProjectMdlForm []byte
//go:embed default-project-rsk.form.yaml
var embeddedDefaultProjectRskForm []byte
// DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes. // DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes.
// Used by callers that need the canonical spec without going through // Used by callers that need the canonical spec without going through
// the URL-recognition path. // the URL-recognition path.
@ -99,16 +93,6 @@ func DefaultProjectMdlTableYAML() []byte { return embeddedDefaultProjectMdlTable
// rsk.table.yaml bytes. // rsk.table.yaml bytes.
func DefaultProjectRskTableYAML() []byte { return embeddedDefaultProjectRskTable } func DefaultProjectRskTableYAML() []byte { return embeddedDefaultProjectRskTable }
// DefaultProjectMdlFormYAML returns the embedded project-rollup
// mdl.form.yaml bytes. Differs from the per-party MDL form by an
// additional required `party` field — the routing key for the
// rollup create handler.
func DefaultProjectMdlFormYAML() []byte { return embeddedDefaultProjectMdlForm }
// DefaultProjectRskFormYAML returns the embedded project-rollup
// rsk.form.yaml bytes.
func DefaultProjectRskFormYAML() []byte { return embeddedDefaultProjectRskForm }
// IsDefaultSpec reports whether urlPath is one of the embedded // IsDefaultSpec reports whether urlPath is one of the embedded
// default-spec virtual files served when no operator file exists on // default-spec virtual files served when no operator file exists on
// disk. Recognized URL shapes: // disk. Recognized URL shapes:
@ -211,14 +195,14 @@ func classifyDefaultSpec(rel string) []byte {
case "table.yaml": case "table.yaml":
return embeddedDefaultProjectMdlTable return embeddedDefaultProjectMdlTable
case "form.yaml": case "form.yaml":
return embeddedDefaultProjectMdlForm return embeddedDefaultMdlForm
} }
case "rsk": case "rsk":
switch file { switch file {
case "table.yaml": case "table.yaml":
return embeddedDefaultProjectRskTable return embeddedDefaultProjectRskTable
case "form.yaml": case "form.yaml":
return embeddedDefaultProjectRskForm return embeddedDefaultRskForm
} }
} }
} }

View file

@ -1253,11 +1253,7 @@ body.is-elevated::after {
left-border swatch; the row tooltip on hover surfaces the state. left-border swatch; the row tooltip on hover surfaces the state.
Colors track the state's urgency: dirty (subtle), saving (info), Colors track the state's urgency: dirty (subtle), saving (info),
queued (warm), invalid/stale (warning), errored (alert). */ queued (warm), invalid/stale (warning), errored (alert). */
/* Dirty row gets a wider swatch (4px → easier to see at a glance) AND .zddc-table__row--dirty td:first-child { box-shadow: inset 3px 0 0 var(--color-info, #4a90e2); }
a faint blue background so the unsaved state reads as "row is in a
different state" not "small marker on the edge". */
.zddc-table__row--dirty td:first-child { box-shadow: inset 4px 0 0 var(--color-info, #4a90e2); }
.zddc-table__row--dirty { background: var(--color-bg-info, rgba(74, 144, 226, 0.08)); }
.zddc-table__row--saving td:first-child { box-shadow: inset 3px 0 0 var(--color-muted, #888); } .zddc-table__row--saving td:first-child { box-shadow: inset 3px 0 0 var(--color-muted, #888); }
.zddc-table__row--queued td:first-child { box-shadow: inset 3px 0 0 var(--color-warm, #d4a017); } .zddc-table__row--queued td:first-child { box-shadow: inset 3px 0 0 var(--color-warm, #d4a017); }
.zddc-table__row--stale td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); background: var(--color-bg-warning, rgba(232, 163, 61, 0.06)); } .zddc-table__row--stale td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); background: var(--color-bg-warning, rgba(232, 163, 61, 0.06)); }
@ -1515,7 +1511,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp">v0.0.18</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 12:37:53 · 847e082-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -1540,7 +1536,6 @@ body.is-elevated::after {
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button> <button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
</div> </div>
<div class="table-toolbar__right"> <div class="table-toolbar__right">
<button type="button" id="table-save" class="btn btn-primary btn-sm" hidden>Save</button>
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button> <button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a> <a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
</div> </div>
@ -4110,7 +4105,6 @@ body.is-elevated::after {
app.state.drafts[rowId] = {}; app.state.drafts[rowId] = {};
} }
app.state.drafts[rowId][field] = value; app.state.drafts[rowId][field] = value;
notifyDraftsChanged();
} }
function clearDraftField(rowId, field) { function clearDraftField(rowId, field) {
@ -4120,17 +4114,6 @@ body.is-elevated::after {
if (Object.keys(r).length === 0) { if (Object.keys(r).length === 0) {
delete app.state.drafts[rowId]; delete app.state.drafts[rowId];
} }
notifyDraftsChanged();
}
// Notify the save module that drafts changed so it can update the
// toolbar Save button + count. Save module is optional in test
// fixtures, so the call is guarded.
function notifyDraftsChanged() {
const save = app.modules.save;
if (save && typeof save.onDraftsChanged === 'function') {
save.onDraftsChanged();
}
} }
function effectiveCellValue(row, col) { function effectiveCellValue(row, col) {
@ -5350,30 +5333,13 @@ body.is-elevated::after {
// Success: clear drafts + invalid marks, capture new ETag. // Success: clear drafts + invalid marks, capture new ETag.
const newEtag = resp.headers.get('ETag'); const newEtag = resp.headers.get('ETag');
if (newEtag) row.etag = newEtag.replace(/"/g, ''); if (newEtag) row.etag = newEtag.replace(/"/g, '');
// For record-typed writes the server echoes the stamped row.data = merged;
// YAML (with server-managed audit fields) back as the
// response body — parse it and overwrite row.data so the
// table sees the same bytes that just landed on disk.
// Falls back to the local merge when the server didn't
// echo a body (non-record write or older server).
let serverData = null;
const ct = (resp.headers.get('Content-Type') || '').toLowerCase();
if (ct.includes('yaml') && window.jsyaml) {
try {
const text = await resp.text();
if (text && text.trim()) serverData = window.jsyaml.load(text);
} catch (e) {
console.warn('[tables] server response YAML parse failed; using local merge', e);
}
}
row.data = serverData || merged;
delete app.state.drafts[rowId]; delete app.state.drafts[rowId];
clearCellInvalid(rowId); clearCellInvalid(rowId);
setRowState(rowId, ''); setRowState(rowId, '');
// If a status prompt was up for this row, drop it. // If a status prompt was up for this row, drop it.
const sb = document.getElementById('table-status'); const sb = document.getElementById('table-status');
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus(); if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
updateSaveButton();
return { status: 'ok' }; return { status: 'ok' };
} }
@ -5384,7 +5350,6 @@ body.is-elevated::after {
row.data = merged; row.data = merged;
delete app.state.drafts[rowId]; delete app.state.drafts[rowId];
setRowState(rowId, 'queued'); setRowState(rowId, 'queued');
updateSaveButton();
return { status: 'queued' }; return { status: 'queued' };
} }
@ -5615,51 +5580,6 @@ body.is-elevated::after {
} }
} }
// flushAll fires saves for every dirty row and returns when they
// all settle. Used by the explicit Save button and the auto-save
// when focus leaves the grid. Unlike flushAllDrafts, this is NOT
// keepalive — the page isn't going anywhere, so we wait for real
// responses and surface errors normally.
async function flushAll() {
const drafts = app.state.drafts || {};
const ids = Object.keys(drafts).filter(id => drafts[id] && Object.keys(drafts[id]).length > 0);
if (ids.length === 0) return { status: 'noop' };
const results = await Promise.allSettled(ids.map(id => saveRow(id)));
const ok = results.filter(r => r.status === 'fulfilled' && r.value && r.value.status === 'ok').length;
return { status: 'done', total: ids.length, ok: ok, failed: ids.length - ok };
}
// Count rows that have at least one unsaved field.
function dirtyCount() {
const drafts = app.state.drafts || {};
let n = 0;
for (const id in drafts) {
if (drafts[id] && Object.keys(drafts[id]).length > 0) n++;
}
return n;
}
// Update the toolbar Save button visibility + label from current
// draft state. Called from editor.js whenever drafts mutate; also
// safe to call anytime (e.g. after a paint).
function updateSaveButton() {
const btn = document.getElementById('table-save');
if (!btn) return;
const n = dirtyCount();
if (n === 0) {
btn.hidden = true;
btn.textContent = 'Save';
return;
}
btn.hidden = false;
btn.textContent = n === 1 ? 'Save (1 unsaved)' : 'Save (' + n + ' unsaved)';
}
function onDraftsChanged() {
updateSaveButton();
markAllDirtyRows();
}
// Window unload handler — call any in-flight drafts so the user // Window unload handler — call any in-flight drafts so the user
// doesn't lose typing on tab-close. The PUT uses keepalive:true so // doesn't lose typing on tab-close. The PUT uses keepalive:true so
// it survives navigation; that comes with a 64 KB body cap. // it survives navigation; that comes with a 64 KB body cap.
@ -5672,11 +5592,7 @@ body.is-elevated::after {
useMine: useMine, useMine: useMine,
reload: reload, reload: reload,
onSelectionChanged: onSelectionChanged, onSelectionChanged: onSelectionChanged,
onDraftsChanged: onDraftsChanged,
markAllDirtyRows: markAllDirtyRows, markAllDirtyRows: markAllDirtyRows,
updateSaveButton: updateSaveButton,
flushAll: flushAll,
dirtyCount: dirtyCount,
flushAllDrafts: flushAllDrafts, flushAllDrafts: flushAllDrafts,
}; };
})(window.tablesApp); })(window.tablesApp);
@ -5837,29 +5753,9 @@ body.is-elevated::after {
const rangeRows = ctx.rangeRowIds || []; const rangeRows = ctx.rangeRowIds || [];
const inRange = rangeRows.length > 1 && rangeRows.indexOf(ctx.rowId) !== -1; const inRange = rangeRows.length > 1 && rangeRows.indexOf(ctx.rowId) !== -1;
const targets = inRange ? rangeRows : [ctx.rowId]; const targets = inRange ? rangeRows : [ctx.rowId];
const items = [];
// Edit row — opens the schema-driven form-mode editor for
// this row. row.url is already the <id>.yaml.html form URL
// (the form handler unwraps virtual-view URLs server-side, so
// SSR + rollup rows route to their per-party canonical paths
// automatically). Disabled on multi-row range and unsaved
// draft rows (no backing file yet).
const singleRow = targets.length === 1 ? ctx.row : null;
const editUrl = singleRow && !singleRow.isNew && singleRow.url ? singleRow.url : null;
items.push({
label: 'Edit row',
icon: '✎',
disabled: !editUrl,
action: function () {
if (editUrl) window.location.href = editUrl;
}
});
items.push({ separator: true });
const label = targets.length > 1 ? 'Delete ' + targets.length + ' rows' : 'Delete row'; const label = targets.length > 1 ? 'Delete ' + targets.length + ' rows' : 'Delete row';
items.push({ return [
{
label: label, label: label,
icon: '🗑', icon: '🗑',
danger: true, danger: true,
@ -5868,9 +5764,8 @@ body.is-elevated::after {
if (targets.length > 1) deleteRows(targets); if (targets.length > 1) deleteRows(targets);
else deleteRow(targets[0]); else deleteRow(targets[0]);
} }
}); }
];
return items;
} }
function onRowContext(ev) { function onRowContext(ev) {
@ -6412,7 +6307,6 @@ body.is-elevated::after {
const clearBtn = document.getElementById('table-clear-filters'); const clearBtn = document.getElementById('table-clear-filters');
const addRowBtn = document.getElementById('table-add-row'); const addRowBtn = document.getElementById('table-add-row');
const exportBtn = document.getElementById('table-export-csv'); const exportBtn = document.getElementById('table-export-csv');
const saveBtn = document.getElementById('table-save');
// Add-row button: appends a draft row inline. Save fires on // Add-row button: appends a draft row inline. Save fires on
// row-blur, which POSTs to <dir>/form.html and swaps the // row-blur, which POSTs to <dir>/form.html and swaps the
@ -6421,50 +6315,6 @@ body.is-elevated::after {
// context loaded with columns) — the test-fixture inline-context // context loaded with columns) — the test-fixture inline-context
// harness opens tables.html directly with no URL shape, so we // harness opens tables.html directly with no URL shape, so we
// gate on having a column list AND running over http(s). // gate on having a column list AND running over http(s).
// Save: explicit flush of every dirty row. The button is
// hidden until a draft exists; save.onDraftsChanged() (called
// from editor.setDraft / clearDraftField) toggles visibility +
// updates the count label. Backstop for the row-blur trigger,
// which only fires when the user navigates to a different
// ROW in the table — clicking outside the grid entirely never
// fired a save without this.
if (saveBtn) {
saveBtn.addEventListener('click', function () {
const save = app.modules.save;
if (save && typeof save.flushAll === 'function') {
save.flushAll();
}
});
}
// Ctrl+S (Cmd+S on mac) flushes all dirty rows. Capturing
// phase so we beat the browser's "Save Page As" default.
window.addEventListener('keydown', function (ev) {
if ((ev.ctrlKey || ev.metaKey) && (ev.key === 's' || ev.key === 'S')) {
const save = app.modules.save;
if (save && typeof save.dirtyCount === 'function' && save.dirtyCount() > 0) {
ev.preventDefault();
save.flushAll();
}
}
});
// Auto-save when focus leaves the grid entirely (the user
// clicked a header link, the URL bar, etc. without moving to
// another row first). focusout fires for cell-to-cell moves
// too — relatedTarget being outside #table-root distinguishes.
const tableRoot = document.getElementById('table-root');
if (tableRoot) {
tableRoot.addEventListener('focusout', function (ev) {
const next = ev.relatedTarget;
if (next && tableRoot.contains(next)) return;
const save = app.modules.save;
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
save.flushAll();
}
});
}
// Export CSV: client-side build of the current view (filtered + // Export CSV: client-side build of the current view (filtered +
// sorted columns + values). No server round-trip, no auth gate // sorted columns + values). No server round-trip, no auth gate
// — the user already has the data on screen. Shown on every // — the user already has the data on screen. Shown on every
@ -6581,11 +6431,6 @@ body.is-elevated::after {
if (save && typeof save.markAllDirtyRows === 'function') { if (save && typeof save.markAllDirtyRows === 'function') {
save.markAllDirtyRows(); save.markAllDirtyRows();
} }
// Refresh the Save button visibility + count after every
// paint — save flow may have settled drafts in the meantime.
if (save && typeof save.updateSaveButton === 'function') {
save.updateSaveButton();
}
} }
// Public re-paint entry point so other modules (save.useMine / // Public re-paint entry point so other modules (save.useMine /
@ -6769,16 +6614,7 @@ body.is-elevated::after {
const help = (ui && ui['ui:help']) || ''; const help = (ui && ui['ui:help']) || '';
const placeholder = (ui && ui['ui:placeholder']) || ''; const placeholder = (ui && ui['ui:placeholder']) || '';
const widget = (ui && ui['ui:widget']) || ''; const widget = (ui && ui['ui:widget']) || '';
// readonly is honored from either source: an explicit UI override const readonly = !!(ui && ui['ui:readonly']);
// (ui:readonly: true) or the schema's readOnly field. The latter
// is set by the server when augmenting from cascade-locked
// records: entries and for audit fields declared readOnly in the
// *.form.yaml.
const readonly = !!(schema.readOnly) || !!(ui && ui['ui:readonly']);
// x-labels: { code → label } turns a bare enum into a labeled
// dropdown ("ACM — Acme Inc" rather than just "ACM"). Injected
// by the server from the cascade's field_codes:codes map.
const labels = (schema && schema['x-labels']) || null;
const autofocus = !!(ui && ui['ui:autofocus']); const autofocus = !!(ui && ui['ui:autofocus']);
let input; let input;
@ -6824,22 +6660,17 @@ body.is-elevated::after {
if (widget === 'radio') { if (widget === 'radio') {
input = u.h('div', { className: 'form-field__radio-group' }); input = u.h('div', { className: 'form-field__radio-group' });
opts.forEach(function (opt, idx) { opts.forEach(function (opt, idx) {
const codeStr = String(opt);
const radioId = id + '-' + idx; const radioId = id + '-' + idx;
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: codeStr }); const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: String(opt) });
if (value === opt) { if (value === opt) {
radio.checked = true; radio.checked = true;
} }
if (readonly) { if (readonly) {
radio.disabled = true; radio.disabled = true;
} }
let displayText = codeStr;
if (labels && Object.prototype.hasOwnProperty.call(labels, codeStr)) {
displayText = codeStr + ' — ' + labels[codeStr];
}
const lbl = u.h('label', { for: radioId }); const lbl = u.h('label', { for: radioId });
lbl.appendChild(radio); lbl.appendChild(radio);
lbl.appendChild(document.createTextNode(' ' + displayText)); lbl.appendChild(document.createTextNode(' ' + String(opt)));
input.appendChild(lbl); input.appendChild(lbl);
}); });
read = function () { read = function () {
@ -6852,12 +6683,7 @@ body.is-elevated::after {
input.appendChild(u.h('option', { value: '' }, '— select —')); input.appendChild(u.h('option', { value: '' }, '— select —'));
} }
opts.forEach(function (opt) { opts.forEach(function (opt) {
const codeStr = String(opt); const o = u.h('option', { value: String(opt) }, String(opt));
let displayText = codeStr;
if (labels && Object.prototype.hasOwnProperty.call(labels, codeStr)) {
displayText = codeStr + ' — ' + labels[codeStr];
}
const o = u.h('option', { value: codeStr }, displayText);
if (value === opt) { if (value === opt) {
o.selected = true; o.selected = true;
} }
@ -6928,12 +6754,6 @@ body.is-elevated::after {
if (autofocus) { if (autofocus) {
input.autofocus = true; input.autofocus = true;
} }
// Schema-driven HTML pattern attribute. Used as a UX hint
// only — authoritative validation runs server-side via the
// cascade's field_codes.
if (schema.pattern && input.tagName === 'INPUT') {
input.pattern = schema.pattern;
}
read = function () { read = function () {
return input.value === '' ? undefined : input.value; return input.value === '' ? undefined : input.value;
}; };

View file

@ -32,25 +32,11 @@ type Schema struct {
Maximum *float64 `yaml:"maximum" json:"maximum,omitempty"` Maximum *float64 `yaml:"maximum" json:"maximum,omitempty"`
MinLength *int `yaml:"minLength" json:"minLength,omitempty"` MinLength *int `yaml:"minLength" json:"minLength,omitempty"`
MaxLength *int `yaml:"maxLength" json:"maxLength,omitempty"` MaxLength *int `yaml:"maxLength" json:"maxLength,omitempty"`
Pattern string `yaml:"pattern,omitempty" json:"pattern,omitempty"`
Format string `yaml:"format" json:"format,omitempty"` Format string `yaml:"format" json:"format,omitempty"`
AdditionalProperties any `yaml:"additionalProperties" json:"additionalProperties,omitempty"` AdditionalProperties any `yaml:"additionalProperties" json:"additionalProperties,omitempty"`
Title string `yaml:"title" json:"title,omitempty"` Title string `yaml:"title" json:"title,omitempty"`
Description string `yaml:"description" json:"description,omitempty"` Description string `yaml:"description" json:"description,omitempty"`
Default any `yaml:"default" json:"default,omitempty"` Default any `yaml:"default" json:"default,omitempty"`
// ReadOnly: if true, clients render this property as disabled and
// suppress edit affordances. Not enforced by the validator (a
// rejected write is the wrong UX for a read-only field — the
// server strips the value instead). Surface to clients via JSON.
ReadOnly bool `yaml:"readOnly,omitempty" json:"readOnly,omitempty"`
// Labels is an extension used by the form renderer to display a
// human label next to a code in enum-with-labels dropdowns. The
// key matches an entry in Enum; the value is the display label
// (e.g. {"ACM": "Acme Inc"}). Server-injected from the cascade's
// field_codes:codes map; not enforced by the validator.
Labels map[string]string `yaml:"x-labels,omitempty" json:"x-labels,omitempty"`
} }
// Error reports a single validation failure. Path is a JSON Pointer (RFC 6901) // Error reports a single validation failure. Path is a JSON Pointer (RFC 6901)

View file

@ -218,60 +218,6 @@ func EffectivePolicy(fsRoot, dirPath string) (PolicyChain, error) {
return chain, nil return chain, nil
} }
// EffectiveFieldCodes returns the merged field-code vocabulary
// visible at the leaf of this chain. Walks root → leaf, applying
// map-merge per top-level key (a leaf entry for the same code
// replaces the root entry, mirroring mergeOverlay).
//
// Embedded defaults are layered in below the on-disk root unless
// inherit:false on any level dropped them (chain.Embedded is zeroed
// in that case, so reading it as a baseline is safe either way).
func (chain PolicyChain) EffectiveFieldCodes() map[string]FieldCode {
out := map[string]FieldCode{}
for k, v := range chain.Embedded.FieldCodes {
out[k] = v
}
for _, lvl := range chain.Levels {
for k, v := range lvl.FieldCodes {
out[k] = v
}
}
return out
}
// EffectiveRecordRule returns the merged RecordRule for files whose
// basename matches a pattern in any level's Records map. Walks root
// → leaf, mergeRecordRule-combining successive matches so a
// per-folder .zddc can refine an ancestor's rule (add a lock, set a
// default) without restating everything.
//
// pattern is the most-specific pattern that matched (deepest level's
// chosen key); rule is the merged result; ok is false when no level
// declared a matching pattern.
//
// Matching at each level prefers literal-key over glob; see
// matchRecordRule.
func (chain PolicyChain) EffectiveRecordRule(basename string) (string, RecordRule, bool) {
merged := RecordRule{}
any := false
pattern := ""
consider := func(rules map[string]RecordRule) {
if pat, rule, hit := matchRecordRule(rules, basename); hit {
merged = mergeRecordRule(merged, rule)
pattern = pat
any = true
}
}
consider(chain.Embedded.Records)
for _, lvl := range chain.Levels {
consider(lvl.Records)
}
if !any {
return "", RecordRule{}, false
}
return pattern, merged, true
}
// InvalidateCache removes the cached policy for dirPath and all descendants. // InvalidateCache removes the cached policy for dirPath and all descendants.
func InvalidateCache(dirPath string) { func InvalidateCache(dirPath string) {
dirPath = filepath.Clean(dirPath) dirPath = filepath.Clean(dirPath)

View file

@ -145,17 +145,6 @@ paths:
# to received/issued). That lets them set up the # to received/issued). That lets them set up the
# counterparty's own .zddc afterward. # counterparty's own .zddc afterward.
auto_own: true auto_own: true
# SSR record: the party folder's ssr.yaml carries this
# party's vendor / contract / status data. Scoped by
# filename pattern so the lock on `kind` only applies to
# ssr.yaml — the mdl/, rsk/, received/ subfolders are
# untouched. No filename_format because identity is the
# party folder name, not a composed tracking number.
records:
"ssr.yaml":
field_defaults:
kind: SSR
locked: [kind]
paths: paths:
mdl: mdl:
default_tool: tables default_tool: tables
@ -164,16 +153,6 @@ paths:
# tables tool serves it from the embedded default # tables tool serves it from the embedded default
# spec even when the on-disk folder doesn't exist. # spec even when the on-disk folder doesn't exist.
virtual: true virtual: true
# MDL records: each .yaml file is an independent
# deliverable with its own composed tracking number.
# No locks — the row's body fields drive the
# filename, type is free-choice from the deployment's
# field_codes. Operators define field_codes at the
# project root (or higher) to supply the originator /
# discipline / type / sequence vocabularies.
records:
"*.yaml":
filename_format: "{originator}-{phase?}-{project}-{area?}-{discipline}-{type}-{sequence}{suffix?}"
rsk: rsk:
default_tool: tables default_tool: tables
available_tools: [tables] available_tools: [tables]
@ -181,20 +160,6 @@ paths:
# as mdl/. Embedded default-rsk spec backs it when no # as mdl/. Embedded default-rsk spec backs it when no
# operator override is on disk. # operator override is on disk.
virtual: true virtual: true
# RSK records: each .yaml file is a row of a parent
# rsk-type deliverable. The table itself has a
# tracking number (same shape as an MDL deliverable
# with type=RSK); rows append a -{row} suffix. The
# server auto-assigns row within the row-scope group
# on POST-create.
records:
"*.yaml":
filename_format: "{originator}-{phase?}-{project}-{area?}-{discipline}-{type}-{sequence}{suffix?}-{row}"
field_defaults:
type: RSK
locked: [type]
row_field: row
row_scope_fields: [originator, phase, project, area, discipline, type, sequence, suffix]
incoming: incoming:
# incoming/ is the COUNTERPARTY's drop zone. The flow: # incoming/ is the COUNTERPARTY's drop zone. The flow:
# 1. the other party's document controller uploads # 1. the other party's document controller uploads

View file

@ -1,215 +0,0 @@
package zddc
import (
"fmt"
"path"
"regexp"
"gopkg.in/yaml.v3"
)
// FieldCodeKind discriminates the validation behaviour of a field code.
type FieldCodeKind string
const (
// FieldCodeEnum: body value must be one of the keys in Codes.
FieldCodeEnum FieldCodeKind = "enum"
// FieldCodePattern: body value must match Pattern (anchored).
FieldCodePattern FieldCodeKind = "pattern"
// FieldCodeFree: any string passes (Description is human-readable
// only).
FieldCodeFree FieldCodeKind = "free"
)
// FieldCode is one entry in a .zddc field_codes: map. A field code
// declares the allowed shape of one component used in record bodies
// and filename composition (e.g. originator, discipline, type,
// sequence). Operators define these at the project root or higher
// in the cascade; child levels can narrow or replace individual
// codes via the map-merge semantics in mergeOverlay.
//
// One discriminator field (Kind) selects which of the three shape
// fields applies:
//
// - Kind=enum: Codes is a code → human-label map. Labels surface
// in form dropdowns; validation checks membership of the key
// set only.
// - Kind=pattern: Pattern is a regular expression matched against
// the whole value (the server anchors it with ^…$ on compile).
// - Kind=free: no constraint; Description is the only field used
// and it's surfaced as help-text in the form UI.
//
// The struct is intentionally permissive in storage (all shape
// fields are present) but enforces grammar at unmarshal time so
// downstream consumers can rely on the kind matching the populated
// fields.
type FieldCode struct {
Kind FieldCodeKind `yaml:"kind" json:"kind"`
Codes map[string]string `yaml:"codes,omitempty" json:"codes,omitempty"`
Pattern string `yaml:"pattern,omitempty" json:"pattern,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
}
// UnmarshalYAML enforces the discriminated-union grammar. A FieldCode
// must declare exactly one of {codes, pattern} matching its kind; for
// free codes neither is allowed (Description is optional).
func (fc *FieldCode) UnmarshalYAML(node *yaml.Node) error {
// Decode into a plain struct first so we can validate after.
type raw FieldCode
var r raw
if err := node.Decode(&r); err != nil {
return err
}
switch r.Kind {
case FieldCodeEnum:
if len(r.Codes) == 0 {
return fmt.Errorf("field_code kind=enum requires non-empty codes:")
}
if r.Pattern != "" {
return fmt.Errorf("field_code kind=enum must not declare pattern:")
}
case FieldCodePattern:
if r.Pattern == "" {
return fmt.Errorf("field_code kind=pattern requires pattern:")
}
if len(r.Codes) > 0 {
return fmt.Errorf("field_code kind=pattern must not declare codes:")
}
if _, err := regexp.Compile("^(?:" + r.Pattern + ")$"); err != nil {
return fmt.Errorf("field_code kind=pattern: invalid regex: %w", err)
}
case FieldCodeFree:
if len(r.Codes) > 0 || r.Pattern != "" {
return fmt.Errorf("field_code kind=free must not declare codes: or pattern:")
}
case "":
return fmt.Errorf("field_code: kind is required (one of enum|pattern|free)")
default:
return fmt.Errorf("field_code: unknown kind %q (want enum|pattern|free)", r.Kind)
}
*fc = FieldCode(r)
return nil
}
// Validate checks a body value against the FieldCode's rule. Empty
// values are allowed only when the caller treats this code as
// optional — Validate itself doesn't know about optionality, only
// shape.
func (fc FieldCode) Validate(value string) error {
switch fc.Kind {
case FieldCodeEnum:
if _, ok := fc.Codes[value]; !ok {
return fmt.Errorf("value %q is not in the allowed code set", value)
}
case FieldCodePattern:
// Anchor at unmarshal-time would require holding a *Regexp on
// the struct; for simplicity we recompile here. Hot paths can
// cache via a sync.Map keyed by the pattern string if this
// shows up in profiles.
re, err := regexp.Compile("^(?:" + fc.Pattern + ")$")
if err != nil {
return fmt.Errorf("internal: pattern recompile: %w", err)
}
if !re.MatchString(value) {
return fmt.Errorf("value %q does not match pattern %q", value, fc.Pattern)
}
case FieldCodeFree:
// No constraint.
}
return nil
}
// RecordRule is one entry in a .zddc records: map. The map key is a
// filename-basename pattern (literal name like "ssr.yaml" or a glob
// like "*.yaml"); the entry describes the rules that apply to files
// matching that pattern in the directory at-or-below this cascade
// level.
//
// FilenameFormat is a composition template referencing field-code
// keys in braces, with `?` marking optional segments (omitted from
// the filename if the body field is empty or missing). Example:
//
// {originator}-{phase?}-{project}-{type}-{sequence}{suffix?}
//
// Field references must match keys declared in the cascade's
// field_codes: map; ServerSide composition + validation enforces
// that the body fields validate against the codes before composing.
//
// FieldDefaults supplies per-folder default values that the server
// injects when the client omits the field. Combined with Locked, a
// folder can pin a field to a single value (e.g. rsk/ pinning
// type=RSK).
//
// Locked is the list of field names that must not be overridden by
// the client. When the client submits a value that differs from
// FieldDefaults[field], the server returns 422.
//
// RowField names the per-row sequence field for tables whose rows
// are children of a parent deliverable (RSK pattern). When set,
// POST-create requests omit the field and the server assigns the
// next available value within the group identified by
// RowScopeFields. PUT-update preserves the existing value.
//
// RowScopeFields names the fields that, together, identify the
// parent deliverable that a row belongs to. Two records with the
// same scope-field values share a row-numbering sequence.
type RecordRule struct {
FilenameFormat string `yaml:"filename_format,omitempty" json:"filename_format,omitempty"`
FieldDefaults map[string]string `yaml:"field_defaults,omitempty" json:"field_defaults,omitempty"`
Locked []string `yaml:"locked,omitempty" json:"locked,omitempty"`
RowField string `yaml:"row_field,omitempty" json:"row_field,omitempty"`
RowScopeFields []string `yaml:"row_scope_fields,omitempty" json:"row_scope_fields,omitempty"`
}
// mergeRecordRule composes two RecordRules, top taking precedence on
// scalars and FieldDefaults map-merge; Locked is concat-dedupe so
// children can add locks but never unlock. Used by mergeOverlay for
// per-pattern entries in Records.
func mergeRecordRule(base, top RecordRule) RecordRule {
out := base
if top.FilenameFormat != "" {
out.FilenameFormat = top.FilenameFormat
}
out.FieldDefaults = mergeStringMap(out.FieldDefaults, top.FieldDefaults)
out.Locked = mergeStringSlice(out.Locked, top.Locked)
if top.RowField != "" {
out.RowField = top.RowField
}
if len(top.RowScopeFields) > 0 {
// Scope-fields are an ordered list (the composition relies on
// the order); top entirely replaces base when set.
out.RowScopeFields = append([]string(nil), top.RowScopeFields...)
}
return out
}
// matchRecordRule picks the RecordRule that applies to a given file
// basename. Literal-key matches win over glob matches; for globs,
// the first matching entry in iteration order wins (callers wanting
// determinism should structure their patterns disjointly).
//
// Returns ("", RecordRule{}, false) when no entry matches.
func matchRecordRule(rules map[string]RecordRule, basename string) (string, RecordRule, bool) {
if rules == nil {
return "", RecordRule{}, false
}
// Pass 1: exact key.
if r, ok := rules[basename]; ok {
return basename, r, true
}
// Pass 2: glob via path.Match (basename-only, no separators).
for k, v := range rules {
if k == basename {
continue
}
ok, err := path.Match(k, basename)
if err != nil {
// Bad pattern: skip rather than aborting the cascade walk.
continue
}
if ok {
return k, v, true
}
}
return "", RecordRule{}, false
}

View file

@ -343,43 +343,6 @@ type ZddcFile struct {
// menu item. Set in an ancestor .zddc to enable. // menu item. Set in an ancestor .zddc to enable.
OnPlanReview *OnPlanReviewConfig `yaml:"on_plan_review,omitempty" json:"on_plan_review,omitempty"` OnPlanReview *OnPlanReviewConfig `yaml:"on_plan_review,omitempty" json:"on_plan_review,omitempty"`
// FieldCodes declares the vocabulary of "field codes" used as
// components of tracking numbers and as constrained body fields
// on record YAMLs (mdl rows, rsk rows, ssr rows). The map key is
// the field name (originator, project, discipline, type,
// sequence, phase, area, suffix, row, …); the value declares the
// allowed shape via a discriminated union — see FieldCode.
//
// Cascade semantics: map-merge per top-level key (child entirely
// replaces parent's entry for the same code). This mirrors the
// Apps: cascade: a sub-tree can narrow or override a single
// field-code's vocabulary without dropping unrelated codes.
//
// Operators define field_codes at the project root (or higher),
// and lower-level .zddc files inherit. Empty in the embedded
// defaults — every deployment populates this per project.
FieldCodes map[string]FieldCode `yaml:"field_codes,omitempty" json:"field_codes,omitempty"`
// Records declares per-record-type rules keyed by filename
// pattern (literal basename like "ssr.yaml" or a glob like
// "*.yaml"). The entry describes the rules that apply to files
// matching that pattern in the directory the .zddc controls.
//
// See RecordRule for the structure: FilenameFormat (composition
// template), FieldDefaults (per-folder defaults), Locked (fields
// that must not be overridden), RowField + RowScopeFields (for
// table-with-rows record types like RSK).
//
// Cascade semantics: map-merge keyed by pattern; each RecordRule
// merges per-field (scalars overwrite, FieldDefaults map-merge,
// Locked concat-dedupe — see mergeRecordRule).
//
// Filename-pattern scoping is what lets SSR rules live at the
// party-folder level without bleeding onto the mdl/ or rsk/
// subfolders in the same folder: the SSR entry keys on
// "ssr.yaml", which only that one file matches.
Records map[string]RecordRule `yaml:"records,omitempty" json:"records,omitempty"`
// Paths declares virtual sub-directory rules without those // Paths declares virtual sub-directory rules without those
// directories needing to exist on disk. Each key is a single path // directories needing to exist on disk. Each key is a single path
// segment — either a literal name or `*` (matches any segment). // segment — either a literal name or `*` (matches any segment).

View file

@ -253,30 +253,6 @@ func IsSSRCreateURL(urlPath string) (string, bool) {
return project, true return project, true
} }
// IsRollupCreateURL reports whether urlPath is
// /<project>/(mdl|rsk)/form.html — the "+ Add row" target on a
// project-level MDL or RSK rollup view. Returns the project name +
// slot ("mdl" or "rsk") when matched. The rollup-create handler
// reads a `party` field from the body and routes the new row into
// <project>/archive/<party>/<slot>/.
func IsRollupCreateURL(urlPath string) (project, slot string, ok bool) {
if urlPath == "" || urlPath[0] != '/' {
return "", "", false
}
parts := strings.Split(strings.TrimPrefix(urlPath, "/"), "/")
if len(parts) != 3 || parts[2] != "form.html" {
return "", "", false
}
if parts[1] != "mdl" && parts[1] != "rsk" {
return "", "", false
}
project = parts[0]
if project == "" || strings.HasPrefix(project, ".") || strings.HasPrefix(project, "_") {
return "", "", false
}
return project, parts[1], true
}
// StripYAMLHTML returns urlPath with a trailing ".html" stripped iff // StripYAMLHTML returns urlPath with a trailing ".html" stripped iff
// the URL has the form-edit shape ".../<name>.yaml.html". Otherwise // the URL has the form-edit shape ".../<name>.yaml.html". Otherwise
// returns urlPath unchanged + false. The form recognizer calls this // returns urlPath unchanged + false. The form recognizer calls this

View file

@ -158,44 +158,6 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
} }
} }
// FieldCodes: map-merge keyed by field name. Top wins on key
// clash — a sub-tree can narrow or replace a single code's
// vocabulary without dropping unrelated codes. Mirror of Apps.
if len(top.FieldCodes) > 0 {
if out.FieldCodes == nil {
out.FieldCodes = make(map[string]FieldCode, len(top.FieldCodes))
} else {
merged := make(map[string]FieldCode, len(out.FieldCodes)+len(top.FieldCodes))
for k, v := range out.FieldCodes {
merged[k] = v
}
out.FieldCodes = merged
}
for k, v := range top.FieldCodes {
out.FieldCodes[k] = v
}
}
// Records: map-merge keyed by filename pattern. Each entry's
// inner fields merge via mergeRecordRule (scalars overwrite,
// FieldDefaults map-merge, Locked concat-dedupe). Two different
// patterns at different cascade levels coexist as independent
// entries; identical patterns merge their contents.
if len(top.Records) > 0 {
if out.Records == nil {
out.Records = make(map[string]RecordRule, len(top.Records))
} else {
merged := make(map[string]RecordRule, len(out.Records)+len(top.Records))
for k, v := range out.Records {
merged[k] = v
}
out.Records = merged
}
for k, v := range top.Records {
out.Records[k] = mergeRecordRule(out.Records[k], v)
}
}
// Paths: top entirely replaces base if set. Recursive descent of // Paths: top entirely replaces base if set. Recursive descent of
// the walker is what threads ancestor Paths through to the right // the walker is what threads ancestor Paths through to the right
// level — merging Paths maps themselves at this layer would // level — merging Paths maps themselves at this layer would