Compare commits
17 commits
cef7188a77
...
49866f6353
| Author | SHA1 | Date | |
|---|---|---|---|
| 49866f6353 | |||
| fac6e7f0d6 | |||
| 470a34a690 | |||
| bdd14609d1 | |||
| 784ed21a34 | |||
| cd05cd6366 | |||
| 69878532b0 | |||
| 480cb0e4a3 | |||
| 3b2280de7f | |||
| 83c3b332d5 | |||
| d947f616d1 | |||
| d35809cfd8 | |||
| 882d5e4c86 | |||
| f9ba493145 | |||
| 1721b4b1db | |||
| 1604b62477 | |||
| f3d334a221 |
50 changed files with 9926 additions and 2463 deletions
233
AGENTS.md
233
AGENTS.md
|
|
@ -6,16 +6,17 @@
|
||||||
# ── ./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. Channel + release subcommands produce a complete release
|
# left alone. `./build beta` is an internal SHA snapshot for the BMC dev
|
||||||
# bundle in dist/release-output/ (gitignored). Run `./deploy` to publish.
|
# chart (no public artifacts). `./build release` is the canonical stable
|
||||||
# Workflow: alpha = active dev → beta = ready for testing → release = ship.
|
# cut. Run `./deploy` to publish a stable cut.
|
||||||
|
|
||||||
./build # dev build (no release bundle)
|
./build # dev build (no release bundle)
|
||||||
./build alpha # cut alpha (cascades nothing)
|
./build beta # internal SHA snapshot for BMC dev chart
|
||||||
./build beta # cut beta (cascades alpha → beta)
|
# (regenerates embedded/* + chore commit;
|
||||||
./build release # cut stable, coordinated next version
|
# no public artifacts in dist/release-output/)
|
||||||
# (cascades alpha + beta → new stable; tags all nine)
|
./build release # coordinated stable cut, next version
|
||||||
./build release 1.2.0 # cut stable at explicit version
|
# (tags all 8 artifacts at release commit)
|
||||||
|
./build release 1.2.0 # coordinated stable cut, explicit version
|
||||||
./build help
|
./build help
|
||||||
|
|
||||||
# ── ./deploy subcommands ────────────────────────────────────────────────────
|
# ── ./deploy subcommands ────────────────────────────────────────────────────
|
||||||
|
|
@ -29,10 +30,9 @@
|
||||||
# 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 release (rare; prefer ./build alpha|beta|release so versions
|
# Single-tool stable cut (rare; prefer ./build release so versions don't
|
||||||
# don't drift between tools). Same flag form as before.
|
# drift between tools).
|
||||||
sh tool/build.sh --release [<version>|alpha|beta]
|
sh tool/build.sh --release [<version>]
|
||||||
./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.
|
||||||
|
|
||||||
Channel/release cuts seed `dist/release-output/` from the current
|
Stable cuts seed `dist/release-output/` from the current
|
||||||
`/srv/zddc/releases/` (preserving symlinks) before running per-tool
|
`/srv/zddc/releases/` — copying only immutable per-version files
|
||||||
promote, then mutate the channels being cut on top. The bundle is
|
(`<tool>_v<X.Y.Z>.html`, `zddc-server_v<X.Y.Z>_<plat>`) + their `.sig`
|
||||||
therefore always a complete intended-live snapshot, not a sparse diff.
|
sidecars + `pubkey.pem`. The cut writes this version's per-version
|
||||||
The build ends with a **channel-link verifier** that asserts every
|
file + canonical `<tool>.html` / `zddc-server_<plat>` symlinks on top.
|
||||||
`<tool>_{stable,beta,alpha}.html` (and zddc-server's per-platform binary
|
`./deploy --releases` (rsync `--delete-after`) cleanses any stale
|
||||||
mirrors + stub pages) resolves. Build fails if any link is dangling —
|
files in the live tree that this cut didn't include.
|
||||||
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,10 +104,9 @@ 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>_stable.html -> ... channel mirror, follows latest stable
|
# <tool>.html -> ... canonical symlink → current 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_<channel>_<platform> channel binary mirror (symlink)
|
# zddc-server_<platform> canonical per-platform symlink → current stable
|
||||||
# zddc-server_<X>.html stub page surfacing 4 platform DLs
|
# zddc-server_<X>.html stub page surfacing 4 platform DLs
|
||||||
|
|
||||||
helm/
|
helm/
|
||||||
|
|
@ -118,7 +117,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 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.
|
**Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`); hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/`). The live site at `zddc.varasys.io` is `/srv/zddc/` on the deploy host (Caddy bind-mount), populated by `./deploy`. Release artifacts are NOT in git — they're produced by `./build release` into `dist/release-output/` and rsync'd to `/srv/zddc/releases/` by `./deploy --releases`. Each tool has exactly one canonical URL (`<tool>.html`, symlink → current stable) and a set of per-version immutable files (`<tool>_v<X.Y.Z>.html`). Same shape for zddc-server per platform. `shared/build-lib.sh` provides `promote_release` (HTML tools) and `promote_zddc_server` (binaries + matching stub pages); the top-level `./build` seeds per-version immutables from live state, then calls them in lockstep. Older releases are reproducible from any `<tool>-vX.Y.Z` tag in this repo (`git checkout zddc-server-v0.0.8 && ./build release 0.0.8`). No Codeberg release assets, no LFS.
|
||||||
|
|
||||||
## Shared CSS (`shared/base.css`)
|
## Shared CSS (`shared/base.css`)
|
||||||
|
|
||||||
|
|
@ -144,7 +143,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 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).
|
- `{{BUILD_LABEL}}` is substituted in all seven HTML tools via `gsub` in awk (use `gsub`, not `print` — the placeholder is inline in an HTML line). Value is `v<next>-dev · <ts> · <sha>[-dirty]` for plain dev builds, `v<next>-beta · <ts> · <sha>` for `./build beta` snapshot cuts, and `v<X.Y.Z>` for stable releases; computed before the awk step. The shared `is_red` flag controls whether the label is wrapped in a red+bold `<span>` (true for dev/beta, false for stable).
|
||||||
- Cleans up temp files via `trap cleanup EXIT`
|
- 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:
|
||||||
|
|
@ -228,77 +227,60 @@ 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, channels, layout
|
### Releasing — lockstep stable + beta snapshot
|
||||||
|
|
||||||
**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).
|
**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`.
|
||||||
|
|
||||||
**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.
|
**No alpha or beta channels in the public release surface.** Simplified in May 2026 — channel mirrors (`_stable`, `_beta`, `_alpha`) and partial-version pins (`_v<X.Y>`, `_v<X>`) are gone. Each tool has exactly one canonical URL (`<tool>.html`, symlink → current stable) and a set of immutable per-version files (`<tool>_v<X.Y.Z>.html`). Same shape for zddc-server per platform.
|
||||||
|
|
||||||
|
**Storage model.** All release artifacts live on the deploy host at `/srv/zddc/releases/` (Caddy bind-mount, served as `https://zddc.varasys.io/releases/`). Locally they materialize in this repo's `dist/release-output/` (gitignored) when `./build release` runs; `./deploy` rsyncs them out. **No git history holds release artifacts** — older versions are reproducible from any `<tool>-vX.Y.Z` tag (`git checkout zddc-server-v0.0.8 && ./build release 0.0.8`). No Codeberg release assets, no LFS, no third-party mirrors.
|
||||||
|
|
||||||
| Artifact | Type | Layout |
|
| 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>_v<X.Y>.html`, `<tool>_v<X>.html` | symlinks | partial-version pins |
|
| `<tool>.html` | symlink | canonical "current stable" URL per tool — always points at the latest cut's per-version file |
|
||||||
| `<tool>_<channel>.html` | symlink (or real bytes during active channel dev) | mutable channel mirror per tool, channel ∈ {stable, beta, alpha} |
|
| `<tool>_v<X.Y.Z>.html.sig` | real, immutable | Ed25519 detached signature |
|
||||||
|
| `<tool>.html.sig` | symlink | canonical .sig URL (symlink → matching `.sig` of the symlinked target) |
|
||||||
| `zddc-server_v<X.Y.Z>_<platform>` | real binary | per-version cross-compiled binary, platform ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe} |
|
| `zddc-server_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>_<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_<platform>` | symlink | canonical "current stable" per platform |
|
||||||
| `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_v<X.Y.Z>_<platform>.sig` | real | matching detached signature |
|
||||||
| `index.html` | regenerated by `build.sh` | matrix table, one column per tool, one row per release |
|
| `zddc-server_<platform>.sig` | symlink | canonical .sig URL |
|
||||||
|
| `zddc-server.html` | generated stub | current-stable four-platform download page |
|
||||||
|
| `zddc-server_v<X.Y.Z>.html` | generated stub | per-version four-platform download page |
|
||||||
|
| `index.html` | regenerated by `build` | downloads landing page (version dropdown, tool cards, apps composer) |
|
||||||
|
|
||||||
**Single point of truth.** `./build release` is the canonical lockstep cut. It seeds `dist/release-output/` from `/srv/zddc/releases/` (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.
|
**Single point of truth.** `./build release` is the canonical lockstep cut. It seeds `dist/release-output/` from `/srv/zddc/releases/` (only per-version immutables + `.sig` + `pubkey.pem`), forwards each HTML tool's build with the agreed version, calls `promote_zddc_server` (in `shared/build-lib.sh`) to copy the freshly cross-compiled binaries with their canonical symlinks, then `write_zddc_server_stubs_all` regenerates stub pages, then `sign_release_artifacts` produces `.sig` for every new per-version file, then `build_releases_index` rewrites the downloads page. **Then** the top-level build folds the regenerated `zddc/internal/apps/embedded/*` files into a `release: vX.Y.Z lockstep` commit and tags all 8 artifacts at that commit. `./deploy --releases` publishes the bundle.
|
||||||
|
|
||||||
- **Stable** (`./build release` or `--release X.Y.Z`): Writes per-version HTML for the 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.
|
- **Stable** (`./build release` or `--release X.Y.Z`): Writes per-version HTML for the seven HTML tools + per-version binaries for zddc-server (real bytes, immutable) + canonical `<tool>.html` and `zddc-server_<platform>` symlinks. Updates `zddc/internal/apps/embedded/*` to stable-labeled bytes, makes a release commit, tags all 8 (`<tool>-v<X.Y.Z>`) **at that commit** so binaries built from the tag embed clean stable bytes.
|
||||||
- **Beta** (`./build beta`): 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.
|
- **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.
|
||||||
- **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 | `ZDDC_REF` | Embeds |
|
| Image | Chart pin | Embeds |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Prod (Dockerfile.prod, BMCD) | `stable` (latest tag) | Stable-labeled bytes from the tagged release commit |
|
| Prod (Dockerfile.prod, BMCD) | `appVersion: "X.Y.Z"` → tag `zddc-server-v<X.Y.Z>` | Stable-labeled bytes from the tagged release commit |
|
||||||
| Dev (Dockerfile, devshell) | `main` | Beta or stable bytes — whatever the last beta/stable cut wrote |
|
| Dev (Dockerfile, devshell) | `appVersion: "X.Y.Z"` or `"X.Y.Z-beta-<sha>"` → tag or SHA | Stable or beta-snapshot bytes (whichever the chart points at) |
|
||||||
| Local dev iteration | n/a | Use `tool/dist/<tool>.html` directly; binary's embedded copy lags |
|
| 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-alpha · <full-ts> · <sha>[-dirty]` (red), where X.Y.Z is the per-tool next-stable target.
|
- Plain dev: `vX.Y.Z-dev · <full-ts> · <sha>[-dirty]` (red), where X.Y.Z is the next-stable target.
|
||||||
- `--release alpha`: `vX.Y.Z-alpha · <date> · <sha>` (red).
|
- `./build beta`: `vX.Y.Z-beta · <full-ts> · <sha>` (red). Only seen on the dev chart's compiled binary.
|
||||||
- `--release beta`: `vX.Y.Z-beta · <date> · <sha>` (red).
|
- `./build release [X.Y.Z]`: `v<X.Y.Z>` (black).
|
||||||
- `--release [version]`: `v<X.Y.Z>` (black).
|
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### Channel discipline (MUST rules)
|
### Release discipline (MUST rules)
|
||||||
|
|
||||||
The build enforces lockstep mechanically (one command bumps all nine). The rules below are still on you.
|
The build enforces lockstep mechanically (one command bumps all 8). The rules below are still on you.
|
||||||
|
|
||||||
1. **Stable doesn't regress.** No known-broken features that worked in the previous stable. If `v0.0.5` ships with a bug, the path forward is `v0.0.6` with a fix — never edit a previously-published per-version file in place. Stable 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. Per-version files are immutable.
|
||||||
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.
|
2. **Lockstep is the contract.** Don't cut a single tool's stable without bumping the rest. The HTML tool's standalone `sh tool/build.sh --release X.Y.Z` flag still exists as an escape hatch but emits a tag that immediately drifts out of sync with the others.
|
||||||
3. **No backports.** Always cut a new stable at a higher version. Users pinned to an old version stay pinned by choice.
|
3. **No backports.** Always cut a new stable at a higher version. Users pinned to an old version stay pinned by choice.
|
||||||
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`.
|
4. **Beta is internal.** Don't advertise `./build beta` snapshots to users — they're a BMC dev pipeline plumbing concept, not a "preview" release. The canonical URL `<tool>.html` always points at the latest stable.
|
||||||
5. **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.
|
5. **Hotfix path.** For critical bugs: fix on `main`, cut a new stable. Tag the commit message `fix:` or include "hotfix" so intent is visible in `git log`.
|
||||||
6. **Hotfix path.** For critical bugs: fix on `main`, cut a new stable. Tag the commit message `fix:` or include "hotfix" so intent is visible in `git log`.
|
6. **Beta soak before promoting (recommended).** Give a beta-snapshotted build a few days on the dev chart before cutting the same code as stable. Not enforced; use judgment for trivial changes.
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -309,7 +291,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`/`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.)
|
2. Write an `apps:` entry in any `.zddc` along the path. Spec is one of `stable` (canonical "latest stable"), `v0.0.4` (exact-version pin), full URL, or local path. Closer-to-leaf entries win. (Or change `default_tool` / `dir_tool` / `available_tools` to route a different tool entirely.)
|
||||||
|
|
||||||
URL sources fetch once and cache forever in `<ZDDC_ROOT>/_app/<host>/<path>`. To force a re-fetch, delete the cache file. No background refresh, no SHA-256 verification, no admin UI. If a configured URL fetch fails, the server falls back to the embedded copy and emits a one-time WARN log.
|
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.
|
||||||
|
|
||||||
|
|
@ -382,11 +364,15 @@ 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`, 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.
|
**Storage**: spec at `<dir>/form.yaml`. Submission filenames depend on whether the directory has a cascade-declared `records:` rule (see "Records, audit, and history" below):
|
||||||
|
- **No matching `records:` rule** — submissions land at `<dir>/<YYYY-MM-DD>-<email-sanitized>.yaml` (the legacy date+email scheme; still the path for ad-hoc operator-defined forms).
|
||||||
|
- **Matching `records:` rule** (mdl/rsk/ssr and operator-declared records) — filename is composed from body fields via the rule's `filename_format`; for rsk-style rules the server also auto-assigns a per-row sequence within the table-scope group.
|
||||||
|
|
||||||
|
Copying `<dir>` elsewhere copies the spec plus every submission together. ACL applies via the existing `.zddc` cascade.
|
||||||
|
|
||||||
**Round-trip**: v0 is form-as-truth — submission YAML is regenerated from form state on every save; comments in submissions are not preserved. File-as-truth mode (lossless YAML round-trip via the eemeli/yaml Document API) is a v1 feature, needed for hand-edited files like `.zddc` itself.
|
**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`, `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.
|
**Validator subset** (`zddc/internal/jsonschema/`): `type` (string/number/integer/boolean/array/object), `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, `required`, `additionalProperties: false`, `properties`, `items`, `format` (`date`, `email`). Schema also carries three client-facing extensions that survive round-trip but aren't enforced by the validator (the server enforces them via cascade or strip-on-write): `readOnly: true` (UI renders disabled), `x-labels: { code → label }` (paired display text for enum dropdowns). NOT supported in v0: `$ref`, `$defs`, `if/then/else`, `oneOf`/`anyOf`/`allOf`, conditional visibility. The form-spec meta-schema enforces that authors stay in the supported subset.
|
||||||
|
|
||||||
**Renderer subset** (`form/js/`): types listed above, enum (select / `ui:widget: radio`), `format: date|email`, textarea, nested objects, arrays of primitives, arrays of objects with add/remove rows. `ui:show-when` and reorder are v1.
|
**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.
|
||||||
|
|
||||||
|
|
@ -415,14 +401,56 @@ 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).
|
||||||
- **Future per-row history** — `<dir>/.history/<id>/<timestamp>.yaml` if/when version sidecars are added.
|
- **Per-row history** — `<dir>/.history/<base-without-ext>/<RFC3339Nano>-<sha8>.yaml`. Server-managed; one directory per record, one file per archived revision. See "Records, audit, and history" below.
|
||||||
|
|
||||||
**Default-MDL fallback at `archive/<party>/mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive/<party>/mdl/`, presence-based discovery is the rule.
|
**Default-MDL 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 (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`.
|
**Default-MDL columns mirror the tracking-number components** documented at `zddc.varasys.io/reference.html#tracking-numbers`: `originator`, `phase`, `project`, `area`, `discipline`, `type`, `sequence`, `suffix` — each one a slot of the deliverable's permanent identifier — plus `title`, `plannedRevision`, `plannedDate`, `status`, `owner`. The form schema accepts free-text on every component by default. Projects narrow the vocabulary via the cascade's `field_codes:` (see below) without rewriting the schema — operator overrides at `archive/<party>/mdl/{table,form}.yaml` still win atomically over the embedded defaults. Source: `zddc/internal/handler/default-mdl.{table,form}.yaml`.
|
||||||
|
|
||||||
**Adding a new table**: create a directory `<dir>/` and drop `table.yaml` (and optionally `form.yaml` for row editing) into it. No code change required. Visit `<dir>/table.html`.
|
**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:
|
||||||
|
|
@ -436,6 +464,46 @@ 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).
|
||||||
|
|
@ -452,7 +520,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 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/`.
|
The repo's top-level `./build` cross-compiles the four release binaries (linux/amd64, darwin/amd64, darwin/arm64, windows/amd64) into `zddc/dist/` via a containerized Go toolchain (podman or docker). On `./build release` it also promotes those binaries to `dist/release-output/` with their per-platform canonical symlinks + stub pages — same lockstep flow as the HTML tools. `./deploy` rsyncs the bundle to `/srv/zddc/releases/`.
|
||||||
|
|
||||||
### Test
|
### Test
|
||||||
|
|
||||||
|
|
@ -617,19 +685,18 @@ 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 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.
|
zddc-server has no separate release script. The top-level `./build release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with their per-platform canonical symlinks (`zddc-server_<platform>` → `zddc-server_v<X.Y.Z>_<platform>`), regenerates the per-version + canonical stub pages, refreshes the index, and tags `zddc-server-v<X.Y.Z>` alongside the seven HTML-tool tags.
|
||||||
|
|
||||||
```sh
|
```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 alpha # lockstep alpha cut for everything
|
./build beta # internal SHA snapshot for the BMC dev chart
|
||||||
./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 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.
|
**Versioning** — clean semver. Stable cuts emit one `<tool>-vX.Y.Z` tag per tool, all 8 sharing the same X.Y.Z. No `-alpha.N` / `-beta.N` counter tags — the canonical URL `<tool>.html` is the stable URL; counters would defeat that. Historical per-tool independent tags (`archive-v0.0.2`, `zddc-server-v0.0.7`, etc.) stay as artifacts; the next coordinated cut jumps every tool to the same number.
|
||||||
|
|
||||||
**Binary distribution** — `/srv/zddc/releases/zddc-server_<X>_<platform>` (on the deploy host) are real static files served from `zddc.varasys.io/releases/`. No Codeberg release assets, no `$CODEBERG_TOKEN`, no third-party mirror, no LFS. The matrix-cell link points at `zddc-server_<X>.html`, a generated stub page that surfaces the four platform downloads in one click.
|
**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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 alpha|beta|release)
|
~/src/zddc/dist/release-output/ (gitignored, produced by ./build 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>.html → ... # symlink: latest patch within X.Y.*
|
<tool>_v<X.Y.Z>.html.sig # detached Ed25519 signature
|
||||||
<tool>_v<X>.html → ... # symlink: latest within X.*.*
|
<tool>.html → <tool>_v<X.Y.Z>.html # canonical "current stable" symlink
|
||||||
<tool>_stable.html → ... # symlink: current stable HTML
|
<tool>.html.sig → <tool>_v<X.Y.Z>.html.sig # canonical .sig symlink (chains to per-version .sig)
|
||||||
<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>_<platform> → ... # symlink chain (mirrors the HTML cascade per platform)
|
zddc-server_v<X.Y.Z>_<platform>.sig # detached signature
|
||||||
zddc-server_v<X>_<platform> → ...
|
zddc-server_<platform> → ... # canonical per-platform symlink → current stable
|
||||||
zddc-server_<channel>_<platform> → ... # channel mirror per platform
|
zddc-server_<platform>.sig → ... # canonical .sig symlink
|
||||||
zddc-server_<X>.html # generated stub: cell link → fans out 4 platform downloads
|
zddc-server_v<X.Y.Z>.html # per-version stub: 4 platform downloads for that version
|
||||||
|
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. 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.
|
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.
|
||||||
|
|
||||||
**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.
|
**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).
|
||||||
|
|
||||||
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`.
|
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`.
|
||||||
|
|
||||||
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,42 +105,36 @@ 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 <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).
|
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.
|
||||||
|
|
||||||
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 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.
|
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`.
|
||||||
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.
|
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.
|
||||||
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 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).
|
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.
|
||||||
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/`.
|
5. Calls `write_zddc_server_stubs_all` to refresh the per-version + canonical 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`.
|
||||||
|
|
||||||
### Channels
|
### Release verbs
|
||||||
|
|
||||||
Three release channels, applied in lockstep across all nine artifacts (8 HTML + zddc-server). The cascade rule keeps downstream channel symlinks current automatically.
|
Two release verbs (plus dev). The May 2026 simplification dropped alpha and made beta internal-only.
|
||||||
|
|
||||||
- **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).
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **Alpha** — `./build alpha` overwrites only the alpha mirrors, all nine artifacts. No tag, no other side-effects.
|
- **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/`.
|
||||||
|
|
||||||
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 on-page `{{BUILD_LABEL}}` is rendered red+bold for dev/beta builds (`is_red=1`) and black for stable releases. The label format is:
|
||||||
|
|
||||||
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-alpha · 2026-04-27 14:00:00 · abc1234[-dirty]` |
|
| dev (no `--release`) | `v0.0.6-dev · 2026-04-27 14:00:00 · abc1234[-dirty]` |
|
||||||
| `--release alpha` | `v0.0.6-alpha · 2026-04-27 · abc1234` |
|
| `--release beta` | `v0.0.6-beta · 2026-04-27 14:00:00 · 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 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).
|
`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.
|
||||||
|
|
||||||
### Install distribution model
|
### Install distribution model
|
||||||
|
|
||||||
|
|
@ -161,7 +155,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`/`beta`/`alpha` (canonical channel), `v0.0.4`/`v0.0`/`v0` (canonical version), 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` (canonical "current stable"), `v0.0.4` (exact-version pin), 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.
|
||||||
|
|
@ -177,10 +171,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`, `verify_channel_links`)
|
- 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`)
|
||||||
- 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>|alpha|beta]` — explicit version or channel name; otherwise produce a dev build
|
- Accept `--release [<version>]` for stable cuts or `--release beta` for snapshot cuts; otherwise produce a dev build
|
||||||
|
|
||||||
### HTML Embedding Safety
|
### HTML Embedding Safety
|
||||||
|
|
||||||
|
|
@ -474,7 +468,9 @@ 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`. 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` (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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
|
|
||||||
48
CLAUDE.md
48
CLAUDE.md
|
|
@ -35,15 +35,21 @@ 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
|
||||||
|
|
||||||
# Channel/release cuts — produce a complete release bundle in
|
# ./build beta — internal SHA snapshot for the BMC dev chart pipeline.
|
||||||
# dist/release-output/ (gitignored). Cuts seed from the live site
|
# Regenerates zddc/internal/apps/embedded/* and makes a
|
||||||
# (/srv/zddc/releases/) so the bundle is a complete intended-live
|
# `chore(embedded): cut v<X.Y.Z>-beta` commit. NO public artifacts.
|
||||||
# snapshot, not a sparse diff. Run ./deploy to publish.
|
# The chart's appVersion pins to "<X.Y.Z>-beta-<sha>"; its Dockerfile
|
||||||
./build alpha # cut alpha (cascades nothing)
|
# parses the suffix and fetches that SHA from git.
|
||||||
./build beta # cut beta (cascades alpha → beta)
|
./build beta
|
||||||
./build release # cut stable, coordinated next version
|
#
|
||||||
# (cascades alpha + beta → new stable; tags all nine artifacts)
|
# ./build release — coordinated stable cut. Regenerates embedded/,
|
||||||
./build release X.Y.Z # cut stable at explicit version
|
# makes a release commit, tags all 8 artifacts, writes per-tool
|
||||||
|
# <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
|
||||||
|
|
@ -53,8 +59,7 @@ 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 [...] # single-tool release (rare; prefer the lockstep ./build)
|
sh tool/build.sh --release [X.Y.Z] # single-tool stable cut (rare; prefer ./build release)
|
||||||
./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
|
||||||
|
|
@ -68,16 +73,17 @@ 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 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.
|
- **`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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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.)
|
- **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.)
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **Release artifact layout** (in `dist/release-output/`, mirrored to `/srv/zddc/releases/`):
|
||||||
- **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).
|
- 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).
|
||||||
- **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.
|
- 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.
|
||||||
- **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.
|
- No channel mirrors (`_alpha`, `_beta`, `_stable`), no partial-version pins (`_v<X.Y>`, `_v<X>`). Dropped in the May 2026 simplification.
|
||||||
- **`./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.
|
- **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/`, 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.
|
||||||
|
|
|
||||||
41
README.md
41
README.md
|
|
@ -22,6 +22,47 @@ 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>.
|
||||||
|
|
|
||||||
|
|
@ -116,4 +116,7 @@ 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
|
||||||
|
|
|
||||||
351
build
351
build
|
|
@ -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,
|
||||||
# and zddc/internal/apps/embedded/ is left alone
|
# 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 alpha cut alpha: produce a complete release bundle
|
# ./build beta internal SHA snapshot for the BMC dev chart.
|
||||||
# in dist/release-output/ (cascades nothing).
|
# Updates embedded/ with current tool HTMLs +
|
||||||
# Like dev, embedded/ is NOT updated — the
|
# makes a `chore(embedded): cut v<X.Y.Z>-beta`
|
||||||
# invariant is that alpha labels are never baked
|
# commit; the chart's appVersion pins to that
|
||||||
# into the binary.
|
# SHA via Dockerfile parsing. NO public
|
||||||
# ./build beta cut beta (cascades alpha → beta). Updates
|
# artifact in dist/release-output/.
|
||||||
# embedded/ with beta-labeled tool HTMLs and
|
# ./build release cut coordinated stable. Updates embedded/
|
||||||
# commits them — the dev image (which builds
|
# with stable-labeled bytes, makes a release
|
||||||
# from main) ships those bytes.
|
# commit, tags all 8 artifacts at that commit,
|
||||||
# ./build release cut coordinated stable (cascades alpha + beta
|
# writes <tool>_v<X.Y.Z>.html + <tool>.html
|
||||||
# → new stable; updates embedded/ with stable
|
# symlink for every tool and the zddc-server
|
||||||
# labels, makes a release commit, tags all
|
# per-platform binaries into
|
||||||
# seven tools at that commit). Prod images
|
# dist/release-output/.
|
||||||
# (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 channel/release cut bumps all seven tools (6 HTML +
|
# Lockstep: every release cut bumps all 8 tools (7 HTML + zddc-server)
|
||||||
# zddc-server) together. Coordinated next-stable = max(latest tag) + 1.
|
# together. Coordinated next-stable = max(latest tag) + 1.
|
||||||
#
|
#
|
||||||
# Channel/release cuts write a complete intended-live snapshot to
|
# Stable 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` (or `./deploy --releases`)
|
# does NOT touch the live site — run `./deploy --releases` to rsync the
|
||||||
# to rsync the snapshot into /srv/zddc/. The snapshot is built by seeding
|
# snapshot into /srv/zddc/. The snapshot is seeded from the current
|
||||||
# from the current live state (so cascades and the verifier see a
|
# live state's per-version files (so older immutable artifacts are
|
||||||
# complete world), then mutating the channel(s) being cut on top.
|
# preserved), then this cut's <tool>.html canonical symlinks + new
|
||||||
|
# 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, ZDDC_REF=stable): always stable bytes
|
# - prod image (Dockerfile.prod): always stable bytes — chart's
|
||||||
# - dev image (Dockerfile, ZDDC_REF=main): stable OR beta bytes
|
# Dockerfile.prod fetches the source at the latest
|
||||||
# (whatever last beta/
|
# zddc-server-vX.Y.Z tag.
|
||||||
# stable cut wrote)
|
# - dev image (Dockerfile): stable OR beta-snapshot bytes — the
|
||||||
# - alpha is NEVER baked in. Active dev iteration happens via the tool's
|
# chart's appVersion is set to either "X.Y.Z" (stable)
|
||||||
# local dist/<tool>.html, not via the binary's embedded copy.
|
# or "X.Y.Z-beta-<sha>" (snapshot), and Dockerfile
|
||||||
|
# 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, verify_channel_links,
|
# (promote_zddc_server, write_zddc_server_stubs_all,
|
||||||
# _coordinated_next_stable) are in scope. Each tool's build.sh sources it
|
# _coordinated_next_stable) are in scope. Each tool's build.sh sources
|
||||||
# again — that's a no-op on already-defined functions.
|
# it 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 website worktree
|
# RELEASE_CHANNEL empty means dev mode (build only, no release output);
|
||||||
# writes); set means a channel/release cut that promotes to the website
|
# "beta" means an internal SHA snapshot (regenerate embedded/ + commit,
|
||||||
# worktree under $ZDDC_DEPLOY_RELEASES_DIR.
|
# no public artifact); "stable" means a coordinated release cut that
|
||||||
|
# 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 the website worktree.
|
# nothing in release-output.
|
||||||
;;
|
|
||||||
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,22p' "$0" | sed 's/^# \{0,1\}//'
|
sed -n '4,30p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|
@ -112,32 +112,41 @@ 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"
|
||||||
|
|
||||||
# When cutting a channel/release, seed RELEASES_DIR from the current live
|
# On a stable cut, seed RELEASES_DIR from the current live site so the
|
||||||
# site so the resulting bundle is a complete intended-live snapshot, not
|
# resulting bundle is a complete intended-live snapshot, not a sparse
|
||||||
# a sparse one-channel diff. Two reasons:
|
# diff. The seed copies the immutable per-version files
|
||||||
# 1. Per-tool promote_release does cascade writes (beta cut → also
|
# (<tool>_v<X.Y.Z>.html, zddc-server_v<X.Y.Z>_<plat>) plus their .sig
|
||||||
# rewrites alpha to track beta; stable cut → resets alpha + beta).
|
# files. The cut then writes this version's new per-version files +
|
||||||
# The cascade itself is deterministic, but downstream artifacts that
|
# refreshes the canonical <tool>.html / zddc-server_<plat> symlinks on
|
||||||
# were NOT touched by this cut (e.g. older versioned files, the
|
# top. `./deploy --releases` (rsync --delete-after) wipes any stale
|
||||||
# other channel mirrors, partial-version symlinks) still need to be
|
# files in /srv/zddc/releases/ that aren't in the bundle.
|
||||||
# present in the bundle so `./deploy --releases` (rsync
|
#
|
||||||
# --delete-after) doesn't wipe them off the live site.
|
# We skip the seed for beta cuts (no public artifacts to produce).
|
||||||
# 2. verify_channel_links cross-checks the full release tree; it
|
# Bootstrap case (no live site yet, or empty live releases dir) is
|
||||||
# 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 [ -n "$RELEASE_CHANNEL" ]; then
|
if [ "$RELEASE_CHANNEL" = "stable" ]; 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 ==="
|
echo "=== Seeding $RELEASES_DIR from $LIVE_RELEASES (per-version artifacts only) ==="
|
||||||
rm -rf "$RELEASES_DIR"
|
rm -rf "$RELEASES_DIR"
|
||||||
mkdir -p "$RELEASES_DIR"
|
mkdir -p "$RELEASES_DIR"
|
||||||
# cp -a preserves the symlink graph (channel mirrors +
|
# Copy per-version immutable files + their .sig sidecars only.
|
||||||
# _v<X.Y> / _v<X> partial-version pins) so cascade decisions
|
# Strict X.Y.Z match avoids picking up legacy partial-version
|
||||||
# downstream see the same world the live site has.
|
# pins (_v<X.Y>, _v<X>) that may still be lying around as
|
||||||
cp -a "$LIVE_RELEASES/." "$RELEASES_DIR/"
|
# leftover .sig files in /srv/zddc/releases/ from the pre-
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
@ -183,11 +192,10 @@ 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 — that's the project invariant: alpha labels are
|
# on a beta or stable cut — beta cuts feed the dev image (chart pins by
|
||||||
# never baked into the binary, beta labels go to the dev image (which builds
|
# SHA to the embedded-commit), stable cuts feed the prod image (chart
|
||||||
# from main), and stable labels go to prod (which builds from the latest
|
# pins to the tag). Plain `./build` leaves embedded files untouched —
|
||||||
# stable tag). Plain `./build` and `./build alpha` leave the embedded files
|
# whatever the last beta or stable cut committed stays in place.
|
||||||
# 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"
|
||||||
|
|
@ -304,10 +312,12 @@ echo " binary version: $ZDDC_BINARY_VERSION"
|
||||||
'
|
'
|
||||||
|
|
||||||
# --- Sign release artifacts -----------------------------------------------
|
# --- Sign release artifacts -----------------------------------------------
|
||||||
# After a channel/release cut has populated $RELEASES_DIR with the actual
|
# After a stable cut has populated $RELEASES_DIR with the actual bytes
|
||||||
# bytes for this build, walk the dir and produce a detached Ed25519 .sig
|
# for this build, walk the dir and produce a detached Ed25519 .sig
|
||||||
# alongside every real artifact. Symlinks (channel mirrors, partial-version
|
# alongside every immutable per-version artifact. Canonical symlinks
|
||||||
# pins) skip — the .sig at the symlink's target is what counts.
|
# (<tool>.html, zddc-server_<plat>) skip — the .sig at the symlink's
|
||||||
|
# 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:
|
||||||
#
|
#
|
||||||
|
|
@ -341,14 +351,15 @@ 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, <tool>_<channel>.html,
|
# (-P, the default), matching <tool>_v*.html and
|
||||||
# and zddc-server_v*_<plat>(.exe). Excludes the index, stub pages,
|
# zddc-server_v*_<plat>(.exe). The canonical symlinks (<tool>.html /
|
||||||
# and any pre-existing .sig files.
|
# zddc-server_<plat>) don't get separate .sig files — verification
|
||||||
|
# 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 '*_stable.html' -o -name '*_beta.html' -o -name '*_alpha.html' -o \
|
-name 'zddc-server_v*' \
|
||||||
-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
|
||||||
|
|
@ -377,22 +388,22 @@ sign_release_artifacts() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Promote zddc-server release artifacts ---------------------------------
|
# --- Promote zddc-server release artifacts ---------------------------------
|
||||||
# On a channel/release cut, copy the freshly cross-compiled binaries to
|
# On a stable cut, copy the freshly cross-compiled binaries to the
|
||||||
# the website worktree's releases/ under their canonical names +
|
# release-output bundle under their canonical names + symlinks.
|
||||||
# symlinks. promote_zddc_server also re-runs write_zddc_server_stubs_all
|
# promote_zddc_server also re-runs write_zddc_server_stubs_all
|
||||||
# internally, so the matrix-cell stub pages get regenerated in the same
|
# internally, so the per-version + canonical stub pages get regenerated
|
||||||
# call. On a plain dev build, skip — we don't touch the worktree.
|
# in the same call. Beta cuts produce no public binary artifact.
|
||||||
if [ -n "$RELEASE_CHANNEL" ]; then
|
if [ "$RELEASE_CHANNEL" = "stable" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Promoting zddc-server $RELEASE_CHANNEL release ==="
|
echo "=== Promoting zddc-server stable release ==="
|
||||||
promote_zddc_server "$RELEASE_CHANNEL" "$RELEASE_VERSION" "$RELEASES_DIR" "$SCRIPT_DIR/zddc/dist"
|
promote_zddc_server "stable" "$RELEASE_VERSION" "$RELEASES_DIR" "$SCRIPT_DIR/zddc/dist"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Latest stable version, by following archive_stable.html → versioned target.
|
# Latest stable version, by following archive.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_stable.html"
|
_link="$RELEASES_DIR/archive.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
|
||||||
|
|
@ -403,18 +414,6 @@ _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
|
||||||
|
|
@ -450,9 +449,6 @@ 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>
|
||||||
|
|
@ -503,39 +499,21 @@ build_releases_index() {
|
||||||
<select id="version-picker">
|
<select id="version-picker">
|
||||||
HEAD
|
HEAD
|
||||||
|
|
||||||
# Channels — selectable directly so users can copy the channel-
|
# "latest" — the canonical URL <tool>.html, a symlink that always
|
||||||
# mirror URLs (e.g. archive_stable.html) for bookmarks. stable is
|
# follows the most recently cut stable. Use this when you want
|
||||||
# the default. The label tells the truth about the channel's
|
# auto-updates. Default option so the page works fully without JS.
|
||||||
# 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="stable" selected>stable — currently v%s</option>\n' "$_latest"
|
printf ' <option value="latest" selected>latest stable — currently v%s</option>\n' "$_latest"
|
||||||
else
|
else
|
||||||
printf ' <option value="stable" selected>stable</option>\n'
|
printf ' <option value="latest" selected>latest 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. These are the immutable URLs
|
# Pinned per-version, latest first. Immutable URLs — pin one
|
||||||
# for reproducibility. No "(current stable)" suffix because the
|
# into your archive when you depend on a specific behavior.
|
||||||
# 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</option>\n' "$_v" "$_v"
|
printf ' <option value="v%s">v%s (pinned)</option>\n' "$_v" "$_v"
|
||||||
done
|
done
|
||||||
printf ' </optgroup>\n'
|
|
||||||
|
|
||||||
cat <<'PICKER_END'
|
cat <<'PICKER_END'
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -552,24 +530,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_stable_linux-amd64" ]; then
|
if [ -e "$RELEASES_DIR/zddc-server_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 channel-mirror URL (zddc-server_stable_<plat>)
|
# Default href is the canonical per-platform URL
|
||||||
# because "stable" is the dropdown's selected option. Picking a
|
# (zddc-server_<plat>), a symlink that always points at the
|
||||||
# pinned version from the dropdown rewrites these to the
|
# latest stable. Picking a pinned version from the dropdown
|
||||||
# immutable per-version URL via the IIFE.
|
# rewrites these to the immutable per-version URL via JS.
|
||||||
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_stable_linux-amd64"\n'
|
printf ' href="zddc-server_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_stable_linux-amd64</span>\n'
|
printf ' <span class="dl-primary-meta" id="dl-primary-meta">zddc-server_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'
|
||||||
|
|
@ -581,7 +559,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_stable_%s%s">%s</a>\n' \
|
printf ' <a data-tool="zddc-server" data-platform="%s" href="zddc-server_%s%s">%s</a>\n' \
|
||||||
"$_plat" "$_plat" "$_suffix" "$_label"
|
"$_plat" "$_plat" "$_suffix" "$_label"
|
||||||
done
|
done
|
||||||
printf ' </div>\n'
|
printf ' </div>\n'
|
||||||
|
|
@ -622,9 +600,9 @@ PATH_B_OPEN
|
||||||
_rest="${_entry#*|}"
|
_rest="${_entry#*|}"
|
||||||
_name="${_rest%%|*}"
|
_name="${_rest%%|*}"
|
||||||
_desc="${_rest#*|}"
|
_desc="${_rest#*|}"
|
||||||
# Default href is the stable-channel mirror; the dropdown
|
# Default href is the canonical symlink <tool>.html; the
|
||||||
# rewires these per selection.
|
# dropdown rewires these per selection.
|
||||||
printf ' <a class="tool-card" data-tool="%s" href="%s_stable.html">\n' "$_t" "$_t"
|
printf ' <a class="tool-card" data-tool="%s" href="%s.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 →</span>\n'
|
printf ' <span class="tool-card__link">Download →</span>\n'
|
||||||
|
|
@ -717,12 +695,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_stable.html</code> → <code class="inline">archive_stable.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.html</code> → <code class="inline">archive.html.sig</code>, etc.). Fetch both, then:</p>
|
||||||
<pre>curl -O https://zddc.varasys.io/releases/archive_stable.html
|
<pre>curl -O https://zddc.varasys.io/releases/archive.html
|
||||||
curl -O https://zddc.varasys.io/releases/archive_stable.html.sig
|
curl -O https://zddc.varasys.io/releases/archive.html.sig
|
||||||
openssl pkeyutl -verify -pubin -inkey pubkey.pem \
|
openssl pkeyutl -verify -pubin -inkey pubkey.pem \
|
||||||
-rawin -in archive_stable.html \
|
-rawin -in archive.html \
|
||||||
-sigfile archive_stable.html.sig</pre>
|
-sigfile archive.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>
|
||||||
|
|
@ -745,7 +723,8 @@ ZDDC_ROOT=/srv/zddc ./zddc-server</pre>
|
||||||
<pre># <ZDDC_ROOT>/.zddc
|
<pre># <ZDDC_ROOT>/.zddc
|
||||||
admins: [you@yourcompany.com]
|
admins: [you@yourcompany.com]
|
||||||
acl:
|
acl:
|
||||||
allow: ["*@yourcompany.com"]
|
permissions:
|
||||||
|
'*@yourcompany.com': r
|
||||||
apps_pubkey: |
|
apps_pubkey: |
|
||||||
-----BEGIN PUBLIC KEY-----
|
-----BEGIN PUBLIC KEY-----
|
||||||
MCowBQYDK2VwAyEAXXaxIUIyOFnhD1eZs02nEt3xZ8izOi7bURFcpJ9iWZY=
|
MCowBQYDK2VwAyEAXXaxIUIyOFnhD1eZs02nEt3xZ8izOi7bURFcpJ9iWZY=
|
||||||
|
|
@ -756,25 +735,6 @@ 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">
|
||||||
|
|
@ -809,18 +769,21 @@ 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 isChannel(v) {
|
|
||||||
return v === 'stable' || v === 'beta' || v === 'alpha';
|
|
||||||
}
|
|
||||||
function platBinaryName(slug, plat) {
|
function platBinaryName(slug, plat) {
|
||||||
// slug is a channel name ("stable") or a pinned version ("v0.0.8").
|
// slug === "latest" → canonical symlink zddc-server_<plat>;
|
||||||
// The on-disk name uses the slug as-is in both cases since the
|
// slug === "v<X.Y.Z>" → immutable per-version zddc-server_v<X.Y.Z>_<plat>.
|
||||||
// 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' : '';
|
var suf = (plat.indexOf('windows') === 0) ? '.exe' : '';
|
||||||
|
if (slug === 'latest') {
|
||||||
|
return 'zddc-server_' + plat + suf;
|
||||||
|
}
|
||||||
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -838,21 +801,25 @@ 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 stable-channel
|
// download link's href. Static markup ships with the canonical
|
||||||
// mirror (`<tool>_stable.html`, `zddc-server_stable_<plat>`) so the
|
// URLs (`<tool>.html`, `zddc-server_<plat>`) so the page works
|
||||||
// page works fully without JS — the JS just keeps things in sync
|
// fully without JS — the JS just keeps things in sync when the
|
||||||
// when the user picks a different channel or pins a version.
|
// user pins a specific 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 ∈ {"stable", "beta", "alpha"} | "v<X.Y.Z>". Every link with
|
// slug === "latest" | "v<X.Y.Z>". Every link with a data-tool
|
||||||
// a data-tool attribute is a download URL the dropdown owns.
|
// 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') {
|
||||||
a.href = plat ? platBinaryName(slug, plat) : ('zddc-server_' + slug + '.html');
|
if (plat) {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
@ -891,13 +858,17 @@ 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 = 'stable'; // default per-app
|
sel.value = 'latest'; // 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) {
|
||||||
lines.push(' ' + sel.dataset.app + ': ' + sel.value);
|
var val = (sel.value === 'latest') ? 'stable' : sel.value;
|
||||||
|
lines.push(' ' + sel.dataset.app + ': ' + val);
|
||||||
});
|
});
|
||||||
textarea.value = lines.join('\n') + '\n';
|
textarea.value = lines.join('\n') + '\n';
|
||||||
}
|
}
|
||||||
|
|
@ -934,9 +905,9 @@ PIN_END
|
||||||
echo "Wrote $_out"
|
echo "Wrote $_out"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Matrix index + verifier only run when we touched the website
|
# Sign artifacts + regenerate releases/index.html on stable cuts.
|
||||||
# worktree. Dev builds leave the worktree alone.
|
# Beta cuts produce no public artifact, so nothing to sign or index.
|
||||||
if [ -n "$RELEASE_CHANNEL" ]; then
|
if [ "$RELEASE_CHANNEL" = "stable" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Signing release artifacts ==="
|
echo "=== Signing release artifacts ==="
|
||||||
sign_release_artifacts "$RELEASES_DIR"
|
sign_release_artifacts "$RELEASES_DIR"
|
||||||
|
|
@ -944,10 +915,6 @@ if [ -n "$RELEASE_CHANNEL" ]; 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) ---------------------------------
|
||||||
|
|
@ -956,12 +923,11 @@ 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 alpha-dirty embedded/*) and prod
|
# the source-side commit (with stale embedded/*) and prod images
|
||||||
# images compiled from `git checkout zddc-server-vX.Y.Z` would
|
# compiled from `git checkout zddc-server-vX.Y.Z` would ship stale
|
||||||
# ship alpha bytes. (Original justification — preserved.)
|
# bytes. (Original justification — preserved.)
|
||||||
#
|
#
|
||||||
# 2. Beta: the dev pipeline pins the chart's appVersion to a SHA
|
# 2. Beta: the dev chart pipeline pins appVersion to a SHA. For that
|
||||||
# (.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
|
||||||
|
|
@ -1037,7 +1003,8 @@ 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 "To cut alpha into a deployable bundle: ./build alpha"
|
echo "For an internal SHA snapshot (BMC dev chart): ./build beta"
|
||||||
|
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
5
deploy
|
|
@ -62,10 +62,13 @@ 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.
|
# under /usr/share/caddy. --exclude=.claude keeps local Claude Code
|
||||||
|
# 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/"
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,16 @@
|
||||||
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']) || '';
|
||||||
const readonly = !!(ui && ui['ui:readonly']);
|
// readonly is honored from either source: an explicit UI override
|
||||||
|
// (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;
|
||||||
|
|
@ -90,17 +99,22 @@
|
||||||
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: String(opt) });
|
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: codeStr });
|
||||||
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(' ' + String(opt)));
|
lbl.appendChild(document.createTextNode(' ' + displayText));
|
||||||
input.appendChild(lbl);
|
input.appendChild(lbl);
|
||||||
});
|
});
|
||||||
read = function () {
|
read = function () {
|
||||||
|
|
@ -113,7 +127,12 @@
|
||||||
input.appendChild(u.h('option', { value: '' }, '— select —'));
|
input.appendChild(u.h('option', { value: '' }, '— select —'));
|
||||||
}
|
}
|
||||||
opts.forEach(function (opt) {
|
opts.forEach(function (opt) {
|
||||||
const o = u.h('option', { value: String(opt) }, String(opt));
|
const codeStr = 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;
|
||||||
}
|
}
|
||||||
|
|
@ -184,6 +203,12 @@
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
#!/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."
|
|
||||||
|
|
@ -17,42 +17,39 @@
|
||||||
# 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 [<channel-or-version>]]
|
# compute_build_label <tool> [--release [<beta-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 "Channels and release args" below.
|
# See "Release args" below.
|
||||||
# promote_release <tool> — for stable / alpha / beta, copy the dist
|
# promote_release <tool> — for stable cuts, copy the dist HTML into
|
||||||
# HTML into the release-output bundle
|
# the release-output bundle (default
|
||||||
# (default $root_dir/../dist/release-output;
|
# $root_dir/../dist/release-output;
|
||||||
# override $ZDDC_DEPLOY_RELEASES_DIR). Stable
|
# override $ZDDC_DEPLOY_RELEASES_DIR).
|
||||||
# cuts write the immutable per-version file +
|
# Writes the immutable per-version file
|
||||||
# refresh five symlinks (_v<X.Y>, _v<X>,
|
# <tool>_v<X.Y.Z>.html plus the canonical
|
||||||
# _stable, _beta, _alpha) and tag
|
# symlink <tool>.html pointing at it.
|
||||||
# <tool>-v<X.Y.Z>. Alpha/beta cuts
|
# Tagging is centralized in the top-level
|
||||||
# overwrite the channel mirror in place
|
# ./build (after the embedded commit).
|
||||||
# and cascade alpha → beta. No git tags
|
# Beta cuts produce NO public artifact —
|
||||||
# for alpha/beta cuts. The bundle is a
|
# they are an internal SHA snapshot for
|
||||||
# complete intended-live snapshot — the
|
# the BMC dev chart pipeline; the
|
||||||
# top-level ./build seeds it from
|
# embedded/* regeneration + chore commit
|
||||||
# /srv/zddc/releases/ before per-tool
|
# in the top-level ./build is the actual
|
||||||
# promote runs, then ./deploy --releases
|
# artifact (chart appVersion pins to that
|
||||||
# rsyncs it back. See ARCHITECTURE.md
|
# SHA, Dockerfile fetches it from git).
|
||||||
# "Channels" for the full table.
|
|
||||||
#
|
#
|
||||||
# Channels and release args:
|
# Release args:
|
||||||
# <none> dev build, tool/dist/ only, label
|
# <none> dev build, tool/dist/ only, label
|
||||||
# "v<next-stable>-alpha · <ts> · <sha>[-dirty]" (red).
|
# "v<next-stable>-dev · <ts> · <sha>[-dirty]" (red).
|
||||||
# No release-output side-effect. To produce a deployable
|
# No release-output side-effect.
|
||||||
# bundle, re-run with `--release alpha`.
|
# --release stable cut, auto-bump patch from latest tag (or 0.0.1).
|
||||||
# --release stable, auto-bump patch from latest tag (or 0.0.1).
|
# Writes <tool>_v<X.Y.Z>.html + <tool>.html symlink;
|
||||||
# Writes per-version file + symlinks; tags vX.Y.Z.
|
# tagged later by ./build.
|
||||||
# --release X.Y.Z stable, explicit version.
|
# --release X.Y.Z stable cut, explicit version.
|
||||||
# --release alpha alpha channel cut at HEAD;
|
# --release beta internal SHA snapshot for the BMC dev chart. Build
|
||||||
# label "v<next-stable>-alpha · <date> · <sha>" (red).
|
# label is "v<next-stable>-beta · <date> · <sha>";
|
||||||
# Overwrites <tool>_alpha.html. No tag.
|
# no public artifact, no tag. The top-level ./build
|
||||||
# --release beta beta channel; label "v<next-stable>-beta · <date> · <sha>".
|
# regenerates zddc/internal/apps/embedded/ + commits.
|
||||||
# Overwrites <tool>_beta.html. Cascades <tool>_alpha.html
|
|
||||||
# → <tool>_beta.html (symlink). No tag.
|
|
||||||
# --release <other> error.
|
# --release <other> error.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
@ -117,7 +114,7 @@ escape_js_close_tags() {
|
||||||
_validate_semver() {
|
_validate_semver() {
|
||||||
_v="$1"
|
_v="$1"
|
||||||
_bad() {
|
_bad() {
|
||||||
echo "error: invalid release argument: '$_v' (expected: alpha, beta, or X.Y.Z stable version)" >&2
|
echo "error: invalid release argument: '$_v' (expected: beta, or X.Y.Z stable version)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
_v1="${_v%%.*}"
|
_v1="${_v%%.*}"
|
||||||
|
|
@ -172,28 +169,25 @@ _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 [<channel-or-version>]]
|
# compute_build_label <tool_name> [--release [<beta-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/alpha/beta), else "0"
|
# is_red — "1" if the label should render red+bold (dev/beta), else "0"
|
||||||
# channel — "stable" / "alpha" / "beta" / "" (dev)
|
# channel — "stable" / "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 builds and
|
# the latest clean tool-vX.Y.Z tag (patch-bump). Plain dev builds and
|
||||||
# `--release alpha`/`--release beta` carry the next-stable target as a
|
# `--release beta` carry the next-stable target as a pre-release suffix
|
||||||
# pre-release suffix in the on-page label so users can see which stable
|
# in the on-page label so users can see which stable the snapshot is
|
||||||
# the alpha/beta is working toward. Stable releases write a clean
|
# working toward. Stable releases write a clean vX.Y.Z label and tag.
|
||||||
# vX.Y.Z label and tag.
|
|
||||||
#
|
#
|
||||||
# HTML tools do NOT tag alpha/beta cuts (consistent with current
|
# HTML tools do NOT tag beta cuts — beta produces no public artifact
|
||||||
# behavior — alpha and beta artifacts are mutable files, not immutable
|
# (the chart pins by SHA via appVersion). Plain dev builds and beta
|
||||||
# per-build snapshots). Plain dev builds and `--release alpha|beta`
|
# cuts share the same on-page label format (full UTC timestamp + short
|
||||||
# cuts share the same on-page label format — full UTC timestamp + short
|
# source SHA). A plain dev build may carry a "-dirty" SHA suffix when
|
||||||
# source SHA — so testers see one rendering shape regardless of how the
|
# the working tree has uncommitted changes; release cuts don't.
|
||||||
# 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:-}"
|
||||||
|
|
@ -208,17 +202,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 builds — labeled as the alpha channel because
|
# Plain builds are dev iteration — tool/dist/ only, no release
|
||||||
# that's what the next formal cut would produce, but no Codeberg upload
|
# output. The label includes the next-stable target so a developer
|
||||||
# happens until `--release alpha` is invoked. Full timestamp (granular
|
# opening the local dist file can see which version-in-progress
|
||||||
# than date) and -dirty marker distinguish iterative dev builds from
|
# they're looking at. Full timestamp + dirty marker distinguish
|
||||||
# formal `--release alpha` cuts (which stamp date-only).
|
# iterative dev builds from formal cuts.
|
||||||
_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="alpha"
|
channel="dev"
|
||||||
build_label="v${_next_stable}-alpha · ${build_timestamp} · ${_sha}"
|
build_label="v${_next_stable}-dev · ${build_timestamp} · ${_sha}"
|
||||||
_emit_build_label_sidecar "$_tool"
|
_emit_build_label_sidecar "$_tool"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
@ -226,14 +220,16 @@ compute_build_label() {
|
||||||
is_release=1
|
is_release=1
|
||||||
|
|
||||||
case "$_arg" in
|
case "$_arg" in
|
||||||
alpha | beta)
|
beta)
|
||||||
channel="$_arg"
|
channel="beta"
|
||||||
# Full UTC timestamp + short source SHA — same format as
|
# Internal SHA snapshot for the BMC dev chart. The chart's
|
||||||
# plain dev builds. _source_commit_short_sha walks past
|
# appVersion gets set to "<next>-beta-<sha>" and the
|
||||||
# any `chore(embedded): cut …` auto-commit at HEAD so a
|
# Dockerfile parses the suffix to fetch this SHA from git.
|
||||||
# re-cut on unchanged source produces the same SHA.
|
# _source_commit_short_sha walks past any `chore(embedded):
|
||||||
|
# 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}-${channel} · ${build_timestamp} · ${_sha}"
|
build_label="v${_next_stable}-beta · ${build_timestamp} · ${_sha}"
|
||||||
_emit_build_label_sidecar "$_tool"
|
_emit_build_label_sidecar "$_tool"
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
|
@ -266,14 +262,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, channel-link verifier).
|
# coordinated next-stable).
|
||||||
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
|
||||||
# alpha/beta on-page label still reads against its own history (e.g. an
|
# on-page label reads against its own history (e.g. a beta cut for a
|
||||||
# alpha cut for a tool that's been quiet still labels itself targeting that
|
# tool that's been quiet still labels itself targeting that tool's next
|
||||||
# tool's next stable, even when the lockstep convention is in force).
|
# 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 \
|
||||||
|
|
@ -315,30 +311,22 @@ _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" / "alpha" / "beta"), $build_version (stable only),
|
# scope: $channel ("stable" / "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. Skip if source unchanged since latest stable tag.
|
# 1. Copy dist HTML → <bundle>/<tool>_v<X.Y.Z>.html (immutable).
|
||||||
# 2. Copy dist HTML → <bundle>/<tool>_v<X.Y.Z>.html (immutable).
|
# 2. Refresh canonical symlink: <bundle>/<tool>.html → the new versioned file.
|
||||||
# 3. Refresh symlinks: _v<X.Y>, _v<X>, _stable, _beta, _alpha all → the
|
# 3. Tag the commit <tool>-v<X.Y.Z> (centralized in the top-level ./build).
|
||||||
# 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>.
|
|
||||||
#
|
#
|
||||||
# Alpha/beta cuts:
|
# Beta cuts:
|
||||||
# 1. Overwrite <bundle>/<tool>_<channel>.html with dist HTML
|
# No public artifact. The chart's Dockerfile fetches the source at the
|
||||||
# (replaces a symlink with real bytes if one was there).
|
# SHA pinned in chart appVersion and compiles its own binary; the
|
||||||
# 2. For beta: cascade <tool>_alpha.html → <tool>_beta.html (symlink),
|
# embedded/* regeneration + chore commit in the top-level ./build is
|
||||||
# since alpha defaults to beta when no active alpha.
|
# the actual snapshot.
|
||||||
# 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
|
||||||
|
|
@ -363,18 +351,16 @@ 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 symlink chain (_v<X.Y>, _v<X>, _stable, _beta, _alpha)
|
# but the canonical symlink <tool>.html advances to the new
|
||||||
# gets advanced to the new version, which is the actual goal.
|
# 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"
|
||||||
;;
|
;;
|
||||||
alpha | beta)
|
beta)
|
||||||
_promote_channel "$_tool" "$channel" "$_releases_dir"
|
# Internal SHA snapshot for the BMC dev chart. No public
|
||||||
|
# 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
|
||||||
|
|
@ -383,35 +369,31 @@ promote_release() {
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# Stable cut: per-version file + 5 symlinks. Tagging is centralized in
|
# Stable cut: per-version immutable file + canonical symlink. Tagging is
|
||||||
# the top-level ./build (it commits embedded artifacts FIRST, then tags
|
# centralized in the top-level ./build (it commits embedded artifacts
|
||||||
# at the new commit — see "Release commit + tag" block at the bottom of
|
# FIRST, then tags at the new commit — see "Release commit + tag" block
|
||||||
# the script). _promote_stable historically created tags itself, but
|
# at the bottom of the script). _promote_stable historically created
|
||||||
# that placed them on the source-side commit before embedded files were
|
# tags itself, but that placed them on the source-side commit before
|
||||||
# folded in, leaving prod binaries with alpha-dirty bytes baked in.
|
# embedded files were folded in, leaving prod binaries with stale bytes
|
||||||
|
# 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"
|
||||||
|
|
||||||
# Refresh the 5 symlinks. Cascade: stable cut → beta + alpha both
|
ln -sfn "$_versioned" "$_rdir/$_canonical"
|
||||||
# reset to stable (no active dev on either downstream channel).
|
echo " $_canonical → $_versioned"
|
||||||
for _sym in "${_t}_v${_major}.${_minor}.html" \
|
|
||||||
"${_t}_v${_major}.html" \
|
# Companion .sig symlink so `curl <canonical>.sig` resolves. The
|
||||||
"${_t}_stable.html" \
|
# actual .sig file is written by sign_release_artifacts; this
|
||||||
"${_t}_beta.html" \
|
# symlink points there.
|
||||||
"${_t}_alpha.html"; do
|
ln -sfn "${_versioned}.sig" "$_rdir/${_canonical}.sig"
|
||||||
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
|
||||||
|
|
@ -431,28 +413,6 @@ _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.
|
||||||
|
|
@ -470,32 +430,36 @@ _zddc_server_platform_label() {
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# Resolve a zddc-server binary's filename for one (version, platform).
|
# Resolve a zddc-server binary's filename for one (slug, platform).
|
||||||
# Returns the bare name (no path); ".exe" suffix on windows.
|
# Returns the bare name (no path); ".exe" suffix on windows. Empty slug
|
||||||
|
# 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() {
|
||||||
_ver_or_chan="$1"
|
_slug="$1"
|
||||||
_plat="$2"
|
_plat="$2"
|
||||||
_suffix=""
|
_suffix=""
|
||||||
case "$_plat" in *windows*) _suffix=".exe" ;; esac
|
case "$_plat" in *windows*) _suffix=".exe" ;; esac
|
||||||
if echo "$_ver_or_chan" | grep -qE '^v[0-9]'; then
|
if [ -z "$_slug" ]; then
|
||||||
# Per-version asset, e.g. zddc-server_v0.0.8_linux-amd64
|
printf 'zddc-server_%s%s' "$_plat" "$_suffix"
|
||||||
printf 'zddc-server_%s_%s%s' "$_ver_or_chan" "$_plat" "$_suffix"
|
|
||||||
else
|
else
|
||||||
# Channel mirror, e.g. zddc-server_stable_linux-amd64
|
printf 'zddc-server_%s_%s%s' "$_slug" "$_plat" "$_suffix"
|
||||||
printf 'zddc-server_%s_%s%s' "$_ver_or_chan" "$_plat" "$_suffix"
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Write the small HTML index page that becomes the matrix cell's link for
|
# Write the small HTML index page that becomes the entry point for a
|
||||||
# a zddc-server release. Lists each platform binary with a download link.
|
# zddc-server release. Lists each platform binary with a download link.
|
||||||
# $1 — release directory (absolute)
|
# $1 — release directory (absolute)
|
||||||
# $2 — slug (e.g. v0.0.8, v0.0, stable, beta, alpha)
|
# $2 — slug ("" for canonical "current stable", or "v0.0.8" per-version)
|
||||||
# $3 — display label (e.g. "v0.0.8", "stable channel")
|
# $3 — display label (e.g. "current stable", "v0.0.8")
|
||||||
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
|
||||||
|
|
@ -541,68 +505,40 @@ 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. Driven by the existing per-version binary files +
|
# release-output bundle: one per-version stub per zddc-server_v*_*
|
||||||
# symlinks that the release flow already maintains; just emits the HTML
|
# binary set, plus a canonical zddc-server.html if the latest-stable
|
||||||
# wrappers for them. Safe to run on every cut (idempotent).
|
# symlinks are in place. Indexed off linux-amd64 since all four
|
||||||
|
# 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"
|
||||||
|
|
||||||
# Every per-version stable binary that exists. We index off
|
# Per-version stubs (immutable).
|
||||||
# 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
|
||||||
|
|
||||||
# Partial-version + channel stubs follow the symlink chain. If the
|
# Canonical stub (follows the latest-stable symlink). Probes the
|
||||||
# symlink resolves to a real binary, write the stub; otherwise skip.
|
# linux-amd64 canonical name; if it exists, the platform symlinks
|
||||||
for _slug in stable beta alpha; do
|
# are in place and we can write the entry page.
|
||||||
_probe="$_rdir/zddc-server_${_slug}_linux-amd64"
|
if [ -e "$_rdir/zddc-server_linux-amd64" ]; then
|
||||||
if [ -e "$_probe" ]; then
|
write_zddc_server_stub "$_rdir" "" "current stable"
|
||||||
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 release cut.
|
# release-output bundle. Called by the top-level ./build on a stable
|
||||||
|
# release cut. Beta cuts produce no public artifact (the chart's
|
||||||
|
# Dockerfile compiles from source at the SHA pinned in appVersion).
|
||||||
#
|
#
|
||||||
# $1 — channel ("stable" | "alpha" | "beta")
|
# $1 — channel ("stable" | "beta")
|
||||||
# $2 — version (X.Y.Z; required for stable; ignored for alpha/beta but
|
# $2 — version (X.Y.Z; required for stable; ignored for beta)
|
||||||
# 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() {
|
||||||
|
|
@ -628,27 +564,21 @@ 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: copy each binary to its immutable name + refresh
|
# Per-version immutable + canonical per-platform symlink.
|
||||||
# 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"
|
||||||
for _sym in "zddc-server_v${_major}.${_minor}_${_plat}${_suffix}" \
|
ln -sfn "$_versioned" "$_rdir/$_canonical"
|
||||||
"zddc-server_v${_major}_${_plat}${_suffix}" \
|
echo " $_canonical → $_versioned"
|
||||||
"zddc-server_stable_${_plat}${_suffix}" \
|
# Companion .sig symlink — see _promote_stable for the
|
||||||
"zddc-server_beta_${_plat}${_suffix}" \
|
# same pattern.
|
||||||
"zddc-server_alpha_${_plat}${_suffix}"; do
|
ln -sfn "${_versioned}.sig" "$_rdir/${_canonical}.sig"
|
||||||
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
|
||||||
|
|
@ -667,22 +597,11 @@ 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)"
|
||||||
;;
|
;;
|
||||||
alpha | beta)
|
beta)
|
||||||
# Mutable channel mirror per platform; cascade alpha → beta on
|
# Internal SHA snapshot — the chart's Dockerfile fetches the
|
||||||
# a beta cut.
|
# source at that SHA and compiles its own binary. No public
|
||||||
for _plat in $ZDDC_SERVER_PLATFORMS; do
|
# binary is published.
|
||||||
_suffix=""
|
echo " zddc-server: beta is internal (no public artifact)"
|
||||||
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
|
||||||
|
|
@ -690,70 +609,6 @@ promote_zddc_server() {
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Refresh every stub page (covers the new release plus any pre-existing).
|
# Refresh stub pages (per-version + canonical).
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,11 @@
|
||||||
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). */
|
||||||
.zddc-table__row--dirty td:first-child { box-shadow: inset 3px 0 0 var(--color-info, #4a90e2); }
|
/* Dirty row gets a wider swatch (4px → easier to see at a glance) AND
|
||||||
|
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)); }
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@
|
||||||
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) {
|
||||||
|
|
@ -118,6 +119,17 @@
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
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
|
||||||
|
|
@ -39,6 +40,50 @@
|
||||||
// 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
|
||||||
|
|
@ -155,6 +200,11 @@
|
||||||
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 /
|
||||||
|
|
|
||||||
|
|
@ -154,9 +154,29 @@
|
||||||
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';
|
||||||
return [
|
items.push({
|
||||||
{
|
|
||||||
label: label,
|
label: label,
|
||||||
icon: '🗑',
|
icon: '🗑',
|
||||||
danger: true,
|
danger: true,
|
||||||
|
|
@ -165,8 +185,9 @@
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -237,13 +237,30 @@
|
||||||
// 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, '');
|
||||||
row.data = merged;
|
// For record-typed writes the server echoes the stamped
|
||||||
|
// 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' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,6 +271,7 @@
|
||||||
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' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -484,6 +502,51 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|
@ -496,7 +559,11 @@
|
||||||
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);
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -1189,13 +1189,14 @@ sidesteps the operator entirely:
|
||||||
|
|
||||||
**Implementation has three parts** that interlock:
|
**Implementation has three parts** that interlock:
|
||||||
|
|
||||||
1. **Signing in the build pipeline.** `./build alpha|beta|release` runs
|
1. **Signing in the build pipeline.** `./build 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 (channel mirrors, partial-version pins) skip — the .sig
|
Symlinks (the canonical `<tool>.html` and `zddc-server_<platform>`
|
||||||
at the symlink target is what counts.
|
URLs) skip — the .sig at the symlink target is what counts; a
|
||||||
|
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`.
|
||||||
|
|
@ -1543,8 +1544,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` / `beta` / `alpha` (canonical upstream channel)
|
- `stable` (canonical upstream "current stable")
|
||||||
- `v0.0.4` / `v0.0` / `v0` (canonical upstream version pin)
|
- `v0.0.4` (canonical upstream exact-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.
|
||||||
|
|
@ -1566,7 +1567,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: alpha # track alpha for this project
|
classifier: v0.0.4 # pin classifier to v0.0.4 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
|
||||||
```
|
```
|
||||||
|
|
@ -1756,7 +1757,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>.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### Env-var contract (for chart consumers)
|
### Env-var contract (for chart consumers)
|
||||||
|
|
||||||
|
|
@ -1794,13 +1795,12 @@ To run unit tests:
|
||||||
|
|
||||||
## Release tagging
|
## Release tagging
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
```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 alpha # lockstep alpha cut
|
./build beta # internal SHA snapshot for the BMC dev chart pipeline
|
||||||
./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 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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,12 @@ 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
|
||||||
|
|
@ -546,6 +552,46 @@ 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.
|
||||||
|
|
@ -1233,6 +1279,15 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 / :beta / :alpha / :v0.0.4 / :v0.0 / :v0 — channel-only
|
// :stable / :v0.0.4 — channel-only
|
||||||
// stable / beta / alpha / v0.0.4 / v0.0 / v0 — channel-only (no leading colon)
|
// stable / v0.0.4 / 0.0.4 — 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" / "beta" / "alpha" / "v0.0.4" / "v0.0" / "v0"
|
Channel string // "stable" (latest), "v0.0.4" (exact version pin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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:beta → error (terminal URL with extra suffix)
|
// https://host/path/file.html:stable → 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,10 +191,12 @@ func parseURLSpec(spec string) (SpecComponents, error) {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidChannelOrVersion reports whether s is `stable`/`beta`/`alpha` or a
|
// isValidChannelOrVersion reports whether s is `stable` (the canonical
|
||||||
// version like `v0.0.4`/`0.0.4`/`v0.0`/`v0`.
|
// "current stable" alias) or an exact-version pin like `v0.0.4` / `0.0.4`.
|
||||||
|
// 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" || s == "beta" || s == "alpha" {
|
if s == "stable" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
rest := strings.TrimPrefix(s, "v")
|
rest := strings.TrimPrefix(s, "v")
|
||||||
|
|
@ -202,7 +204,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 {
|
||||||
|
|
@ -221,7 +223,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" || s == "beta" || s == "alpha" {
|
if s == "stable" {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(s, "v") {
|
if !strings.HasPrefix(s, "v") {
|
||||||
|
|
@ -359,9 +361,18 @@ 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 + "/" + s.app + "_" + channel + ".html",
|
URL: urlPrefix + "/" + name,
|
||||||
}, true, nil
|
}, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,13 @@ 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) {
|
||||||
|
|
@ -38,18 +36,17 @@ 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) {
|
||||||
|
|
@ -64,6 +61,19 @@ 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
|
||||||
|
|
@ -71,14 +81,13 @@ 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:beta", "https://host/some:thing/releases", "beta"},
|
{"https://host/some:thing/releases:v0.0.4", "https://host/some:thing/releases", "v0.0.4"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.spec, func(t *testing.T) {
|
t.Run(tc.spec, func(t *testing.T) {
|
||||||
|
|
@ -192,13 +201,15 @@ 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": "beta"},
|
Apps: map[string]string{"archive": "stable"},
|
||||||
}}}
|
}}}
|
||||||
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)
|
||||||
}
|
}
|
||||||
want := DefaultUpstreamReleases + "/archive_beta.html"
|
// stable channel → canonical URL (no _stable_ suffix); the upstream
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
@ -223,40 +234,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:beta",
|
"default": "https://mirror.example/releases:v0.0.4",
|
||||||
},
|
},
|
||||||
}}}
|
}}}
|
||||||
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_beta.html" {
|
if src.URL != "https://mirror.example/releases/archive_v0.0.4.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) {
|
||||||
// User's example: default=https://zddc.varasys.io/releases:stable,
|
// default=https://zddc.varasys.io/releases:stable, classifier=:v0.0.4
|
||||||
// classifier=:beta → mirror URL with classifier_beta.html.
|
// → classifier pinned to v0.0.4 on the same mirror.
|
||||||
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": ":beta",
|
"classifier": ":v0.0.4",
|
||||||
},
|
},
|
||||||
}}}
|
}}}
|
||||||
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_beta.html" {
|
if src.URL != "https://zddc.varasys.io/releases/classifier_v0.0.4.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) {
|
||||||
// User's example: default=...:stable, archive=https://my.local.stuff/releases
|
// default=...:stable, archive=https://my.local.stuff/releases
|
||||||
// → custom URL + default channel (stable).
|
// → custom URL + default channel (stable, canonical filename).
|
||||||
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{
|
||||||
|
|
@ -268,7 +279,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_stable.html" {
|
if src.URL != "https://my.local.stuff/releases/archive.html" {
|
||||||
t.Errorf("got URL=%q", src.URL)
|
t.Errorf("got URL=%q", src.URL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -278,13 +289,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": ":beta"}},
|
{Apps: map[string]string{"default": ":v0.0.4"}},
|
||||||
}}
|
}}
|
||||||
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_beta.html"
|
want := DefaultUpstreamReleases + "/archive_v0.0.4.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)
|
||||||
}
|
}
|
||||||
|
|
@ -301,8 +312,9 @@ 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).
|
// b.example URL prefix wins; channel inherited (stable → canonical
|
||||||
want := "https://b.example/releases/archive_stable.html"
|
// filename, no _stable_ suffix).
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
@ -329,13 +341,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": "alpha"}}, // non-terminal
|
{Apps: map[string]string{"archive": "v0.0.4"}}, // 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_alpha.html"
|
want := DefaultUpstreamReleases + "/archive_v0.0.4.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)
|
||||||
}
|
}
|
||||||
|
|
@ -382,7 +394,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_stable.html" {
|
if src2.URL != "https://a.example/releases/classifier.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -417,9 +429,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": ":beta"}}}}
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{Apps: map[string]string{"default": ":v0.0.4"}}}}
|
||||||
got := PreviewLine(chain, "archive", root, root)
|
got := PreviewLine(chain, "archive", root, root)
|
||||||
if !strings.Contains(got, "archive_beta.html") {
|
if !strings.Contains(got, "archive_v0.0.4.html") {
|
||||||
t.Errorf("got %q", got)
|
t.Errorf("got %q", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ a:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subdued / de-emphasized variant.
|
/* Subdued / de-emphasized variant.
|
||||||
Used on the "Add Local Directory" button when a tool is operating
|
Used on the "Use 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,6 +331,11 @@ 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
|
||||||
|
|
@ -342,16 +347,35 @@ 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;
|
||||||
|
|
@ -359,6 +383,9 @@ 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.
|
||||||
|
|
@ -809,61 +836,127 @@ body.help-open .app-header {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||||
Sits as a sibling immediately under .app-header (mounted by JS).
|
Renders only for users with admin scope (handled by elevation.js;
|
||||||
Rendered only in online mode when a project segment is in the URL. */
|
the placeholder is `.hidden` by default). When visible, sits left
|
||||||
|
of the theme button — sudo-style affordance for opting into admin
|
||||||
|
powers. */
|
||||||
|
|
||||||
.zddc-stage-strip {
|
.elevation-toggle {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
padding: 0.3rem 1rem;
|
font-size: 0.78rem;
|
||||||
background: var(--bg);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__project {
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
margin-right: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__divider,
|
|
||||||
.zddc-stage-strip__sep {
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
cursor: pointer;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
.zddc-stage-strip__divider {
|
border: 1px solid var(--border);
|
||||||
margin-right: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage {
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.1rem 0.25rem;
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: color 0.15s, background 0.15s;
|
background: var(--bg);
|
||||||
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage:hover {
|
.elevation-toggle:hover {
|
||||||
color: var(--text);
|
background: var(--bg-hover);
|
||||||
background: var(--bg-secondary);
|
border-color: var(--border-dark);
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active {
|
.elevation-toggle input[type="checkbox"] {
|
||||||
color: var(--primary);
|
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;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active:hover {
|
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||||
color: var(--primary);
|
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;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
background: rgba(220, 53, 69, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes elev-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
|
||||||
|
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;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.elevation-banner__off:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||||
|
|
@ -2470,12 +2563,18 @@ 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"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
<span class="build-timestamp">v0.0.18</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use 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>
|
||||||
|
|
@ -2680,7 +2779,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>Add Local Directory</strong> to select an archive folder to browse.</p>
|
<p>Click <strong>Use 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">
|
||||||
|
|
@ -2725,7 +2824,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>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>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>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>
|
||||||
|
|
@ -4048,6 +4147,7 @@ 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 = {};
|
||||||
|
|
@ -4917,211 +5017,6 @@ 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:
|
||||||
|
|
@ -9835,7 +9730,7 @@ window.app.modules.filtering = {
|
||||||
|
|
||||||
// Apply UI differences based on source mode
|
// Apply UI differences based on source mode
|
||||||
function applySourceModeUI() {
|
function applySourceModeUI() {
|
||||||
// "Add Local Directory" button is always visible in both modes —
|
// "Use 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.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -10815,6 +10710,155 @@ 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
|
|
@ -269,7 +269,7 @@ a:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subdued / de-emphasized variant.
|
/* Subdued / de-emphasized variant.
|
||||||
Used on the "Add Local Directory" button when a tool is operating
|
Used on the "Use 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,6 +331,11 @@ 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
|
||||||
|
|
@ -342,16 +347,35 @@ 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;
|
||||||
|
|
@ -359,6 +383,9 @@ 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.
|
||||||
|
|
@ -809,61 +836,127 @@ body.help-open .app-header {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||||
Sits as a sibling immediately under .app-header (mounted by JS).
|
Renders only for users with admin scope (handled by elevation.js;
|
||||||
Rendered only in online mode when a project segment is in the URL. */
|
the placeholder is `.hidden` by default). When visible, sits left
|
||||||
|
of the theme button — sudo-style affordance for opting into admin
|
||||||
|
powers. */
|
||||||
|
|
||||||
.zddc-stage-strip {
|
.elevation-toggle {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
padding: 0.3rem 1rem;
|
font-size: 0.78rem;
|
||||||
background: var(--bg);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__project {
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
margin-right: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__divider,
|
|
||||||
.zddc-stage-strip__sep {
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
cursor: pointer;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
.zddc-stage-strip__divider {
|
border: 1px solid var(--border);
|
||||||
margin-right: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage {
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.1rem 0.25rem;
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: color 0.15s, background 0.15s;
|
background: var(--bg);
|
||||||
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage:hover {
|
.elevation-toggle:hover {
|
||||||
color: var(--text);
|
background: var(--bg-hover);
|
||||||
background: var(--bg-secondary);
|
border-color: var(--border-dark);
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active {
|
.elevation-toggle input[type="checkbox"] {
|
||||||
color: var(--primary);
|
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;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active:hover {
|
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||||
color: var(--primary);
|
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;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
background: rgba(220, 53, 69, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes elev-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
|
||||||
|
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;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.elevation-banner__off:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||||
|
|
@ -1681,12 +1774,18 @@ body.help-open .app-header {
|
||||||
</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"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
<span class="build-timestamp">v0.0.18</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use 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>
|
||||||
|
|
@ -1809,7 +1908,7 @@ body.help-open .app-header {
|
||||||
<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>Add Local Directory</strong> to begin.</p>
|
<p>Click <strong>Use 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>
|
||||||
|
|
@ -1828,7 +1927,7 @@ body.help-open .app-header {
|
||||||
|
|
||||||
<h3>Getting Started</h3>
|
<h3>Getting Started</h3>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Click <strong>Add Local Directory</strong> to open a folder containing files to rename.</li>
|
<li>Click <strong>Use 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>
|
||||||
|
|
@ -3146,6 +3245,7 @@ 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 = {};
|
||||||
|
|
@ -3874,14 +3974,34 @@ X.B(E,Y);return E}return J}())
|
||||||
// Top-level helpers
|
// Top-level helpers
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
// Strip a trailing tool .html (e.g. classifier.html) from a path
|
// Resolve "the directory the tool was opened in" for the current
|
||||||
// to land on the "directory the tool was opened in".
|
// page URL. Two URL shapes serve a tool:
|
||||||
|
//
|
||||||
|
// /…/<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:
|
||||||
//
|
//
|
||||||
|
|
@ -3960,9 +4080,14 @@ 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 resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
|
||||||
{ credentials: 'same-origin' });
|
var resp = await fetch(convertUrl, { 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.';
|
||||||
|
|
@ -4163,211 +4288,6 @@ 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:
|
||||||
|
|
@ -9925,6 +9845,155 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ a:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subdued / de-emphasized variant.
|
/* Subdued / de-emphasized variant.
|
||||||
Used on the "Add Local Directory" button when a tool is operating
|
Used on the "Use 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,6 +331,11 @@ 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
|
||||||
|
|
@ -342,16 +347,35 @@ 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;
|
||||||
|
|
@ -359,6 +383,9 @@ 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.
|
||||||
|
|
@ -809,61 +836,127 @@ body.help-open .app-header {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||||
Sits as a sibling immediately under .app-header (mounted by JS).
|
Renders only for users with admin scope (handled by elevation.js;
|
||||||
Rendered only in online mode when a project segment is in the URL. */
|
the placeholder is `.hidden` by default). When visible, sits left
|
||||||
|
of the theme button — sudo-style affordance for opting into admin
|
||||||
|
powers. */
|
||||||
|
|
||||||
.zddc-stage-strip {
|
.elevation-toggle {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
padding: 0.3rem 1rem;
|
font-size: 0.78rem;
|
||||||
background: var(--bg);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__project {
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
margin-right: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__divider,
|
|
||||||
.zddc-stage-strip__sep {
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
cursor: pointer;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
.zddc-stage-strip__divider {
|
border: 1px solid var(--border);
|
||||||
margin-right: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage {
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.1rem 0.25rem;
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: color 0.15s, background 0.15s;
|
background: var(--bg);
|
||||||
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage:hover {
|
.elevation-toggle:hover {
|
||||||
color: var(--text);
|
background: var(--bg-hover);
|
||||||
background: var(--bg-secondary);
|
border-color: var(--border-dark);
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active {
|
.elevation-toggle input[type="checkbox"] {
|
||||||
color: var(--primary);
|
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;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active:hover {
|
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||||
color: var(--primary);
|
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;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
background: rgba(220, 53, 69, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes elev-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
|
||||||
|
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;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.elevation-banner__off:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||||
|
|
@ -1424,10 +1517,16 @@ 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"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
<span class="build-timestamp">v0.0.18</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>
|
||||||
|
|
@ -1632,6 +1731,7 @@ 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 = {};
|
||||||
|
|
@ -2297,211 +2397,6 @@ 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:
|
||||||
|
|
@ -2632,6 +2527,155 @@ 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.
|
||||||
|
|
@ -2762,13 +2806,27 @@ 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);
|
||||||
allProjects = data.map(function(p) {
|
// The root JSON is now a generic listing.FileInfo[] (same
|
||||||
|
// 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: String(p.name || ''),
|
name: raw,
|
||||||
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);
|
||||||
|
|
|
||||||
|
|
@ -273,7 +273,7 @@ a:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subdued / de-emphasized variant.
|
/* Subdued / de-emphasized variant.
|
||||||
Used on the "Add Local Directory" button when a tool is operating
|
Used on the "Use 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,6 +335,11 @@ 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
|
||||||
|
|
@ -346,16 +351,35 @@ 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;
|
||||||
|
|
@ -363,6 +387,9 @@ 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.
|
||||||
|
|
@ -813,61 +840,127 @@ body.help-open .app-header {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||||
Sits as a sibling immediately under .app-header (mounted by JS).
|
Renders only for users with admin scope (handled by elevation.js;
|
||||||
Rendered only in online mode when a project segment is in the URL. */
|
the placeholder is `.hidden` by default). When visible, sits left
|
||||||
|
of the theme button — sudo-style affordance for opting into admin
|
||||||
|
powers. */
|
||||||
|
|
||||||
.zddc-stage-strip {
|
.elevation-toggle {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
padding: 0.3rem 1rem;
|
font-size: 0.78rem;
|
||||||
background: var(--bg);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__project {
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
margin-right: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__divider,
|
|
||||||
.zddc-stage-strip__sep {
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
cursor: pointer;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
.zddc-stage-strip__divider {
|
border: 1px solid var(--border);
|
||||||
margin-right: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage {
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.1rem 0.25rem;
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: color 0.15s, background 0.15s;
|
background: var(--bg);
|
||||||
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage:hover {
|
.elevation-toggle:hover {
|
||||||
color: var(--text);
|
background: var(--bg-hover);
|
||||||
background: var(--bg-secondary);
|
border-color: var(--border-dark);
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active {
|
.elevation-toggle input[type="checkbox"] {
|
||||||
color: var(--primary);
|
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;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active:hover {
|
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||||
color: var(--primary);
|
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;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
background: rgba(220, 53, 69, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes elev-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
|
||||||
|
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;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.elevation-banner__off:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||||
|
|
@ -2523,11 +2616,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"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
<span class="build-timestamp">v0.0.18</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 "Add Local Directory" here instead) -->
|
other tools have "Use 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">▾</button>
|
<button id="bottom-toggle" type="button" class="btn btn-primary split-button__toggle" data-no-disable="true" aria-haspopup="true" aria-expanded="false">▾</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>
|
||||||
|
|
@ -2535,6 +2628,12 @@ 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>
|
||||||
|
|
@ -4202,6 +4301,7 @@ 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 = {};
|
||||||
|
|
@ -4930,14 +5030,34 @@ X.B(E,Y);return E}return J}())
|
||||||
// Top-level helpers
|
// Top-level helpers
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
// Strip a trailing tool .html (e.g. classifier.html) from a path
|
// Resolve "the directory the tool was opened in" for the current
|
||||||
// to land on the "directory the tool was opened in".
|
// page URL. Two URL shapes serve a tool:
|
||||||
|
//
|
||||||
|
// /…/<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:
|
||||||
//
|
//
|
||||||
|
|
@ -5016,9 +5136,14 @@ 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 resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
|
||||||
{ credentials: 'same-origin' });
|
var resp = await fetch(convertUrl, { 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.';
|
||||||
|
|
@ -5219,211 +5344,6 @@ 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:
|
||||||
|
|
@ -13349,6 +13269,155 @@ 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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.17-beta · 2026-05-13 19:45:42 · 72c0552
|
archive=v0.0.18
|
||||||
transmittal=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
transmittal=v0.0.18
|
||||||
classifier=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
classifier=v0.0.18
|
||||||
landing=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
landing=v0.0.18
|
||||||
form=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
form=v0.0.18
|
||||||
tables=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
tables=v0.0.18
|
||||||
browse=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
browse=v0.0.18
|
||||||
|
|
|
||||||
|
|
@ -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_beta.html"
|
cachedURL := "https://zddc.varasys.io/releases/archive_v0.0.4.html"
|
||||||
cachedBody := []byte("CACHED beta archive")
|
cachedBody := []byte("CACHED v0.0.4 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=beta", nil), "archive", chain, root)
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=v0.0.4", 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=beta", nil), "archive", chain, root)
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=v0.0.4", 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_beta.html"
|
cachedURL := "https://my-mirror.example/releases/archive_v0.0.4.html"
|
||||||
if err := srv.Cache.Write(cachedURL, []byte("MIRROR beta")); err != nil {
|
if err := srv.Cache.Write(cachedURL, []byte("MIRROR v0.0.4")); 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=:beta", nil), "archive", chain, root)
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=:v0.0.4", 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 beta" {
|
if rec.Body.String() != "MIRROR v0.0.4" {
|
||||||
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_stable.html, then cache check.
|
// ?v= overrides → resolves to canonical/archive.html, then cache check.
|
||||||
srv, _, root := newTestServer(t, []byte("ignored"))
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
||||||
cachedURL := "https://zddc.varasys.io/releases/archive_stable.html"
|
cachedURL := "https://zddc.varasys.io/releases/archive.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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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-alpha · 2026-05-01 14:00:00 · abc1234`. An empty
|
// e.g. `archive=v0.0.5-beta · 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).
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -264,7 +264,8 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,13 @@
|
||||||
# (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.
|
# way. Tightening per project is done via .zddc field_codes:, which
|
||||||
|
# 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
|
||||||
|
|
@ -91,6 +97,40 @@ 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
|
||||||
|
|
|
||||||
122
zddc/internal/handler/default-project-mdl.form.yaml
Normal file
122
zddc/internal/handler/default-project-mdl.form.yaml
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
# 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
|
||||||
|
|
@ -8,15 +8,14 @@
|
||||||
# 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 suppressed in this view because the party affiliation
|
# + Add row IS enabled here: the `party` column doubles as the
|
||||||
# would be ambiguous — add deliverables at the per-party path
|
# routing key — the server reads the submitted `party` field, finds
|
||||||
# (<project>/archive/<party>/mdl/) and they'll appear here on next
|
# the matching <project>/archive/<party>/ folder, and writes the row
|
||||||
# load.
|
# inside its mdl/ subfolder. The party folder must already exist
|
||||||
|
# (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 rows at the per-party MDL view.
|
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.
|
||||||
|
|
||||||
addable: false
|
|
||||||
|
|
||||||
columns:
|
columns:
|
||||||
- field: party
|
- field: party
|
||||||
|
|
|
||||||
153
zddc/internal/handler/default-project-rsk.form.yaml
Normal file
153
zddc/internal/handler/default-project-rsk.form.yaml
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
# 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
|
||||||
|
|
@ -7,15 +7,14 @@
|
||||||
# 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 suppressed in this view because the party affiliation
|
# + Add row IS enabled here: the `party` column doubles as the
|
||||||
# would be ambiguous — add risks at the per-party path
|
# routing key — the server reads the submitted `party` field, finds
|
||||||
# (<project>/archive/<party>/rsk/) and they'll appear here on next
|
# the matching <project>/archive/<party>/ folder, and writes the row
|
||||||
# load.
|
# inside its rsk/ subfolder. The party folder must already exist
|
||||||
|
# (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 rows at the per-party RSK view.
|
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.
|
||||||
|
|
||||||
addable: false
|
|
||||||
|
|
||||||
columns:
|
columns:
|
||||||
- field: party
|
- field: party
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,29 @@
|
||||||
# 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
|
||||||
|
|
@ -14,18 +32,67 @@
|
||||||
# the field in the table view too.
|
# the field in the table view too.
|
||||||
|
|
||||||
title: Risk
|
title: Risk
|
||||||
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.
|
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.
|
||||||
|
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [id, title]
|
# `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.
|
||||||
|
required: [originator, project, discipline, sequence, title]
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
id:
|
# --- Table-tracking components: identify which RSK deliverable
|
||||||
|
# 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: ID
|
title: Originator
|
||||||
description: Stable identifier, e.g. R-001.
|
description: Organizational unit responsible for this risk register.
|
||||||
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
|
||||||
|
|
@ -74,6 +141,38 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,40 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -409,11 +409,36 @@ 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)
|
||||||
|
|
@ -421,15 +446,47 @@ 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(body)
|
etag := fileETag(finalBody)
|
||||||
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)
|
||||||
auditFile(r, "put", cleanURL, respStatus, len(body), nil)
|
_, _ = w.Write(finalBody)
|
||||||
|
} 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) {
|
||||||
|
|
|
||||||
|
|
@ -89,9 +89,12 @@ 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 requests. Empty
|
// Project carries the project name for create-via-ssr /
|
||||||
// for all other kinds.
|
// create-via-rollup requests. Empty 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
|
||||||
|
|
@ -130,6 +133,26 @@ 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
|
||||||
|
|
@ -249,6 +272,8 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
@ -305,6 +330,13 @@ 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,
|
||||||
|
|
|
||||||
706
zddc/internal/handler/history.go
Normal file
706
zddc/internal/handler/history.go
Normal file
|
|
@ -0,0 +1,706 @@
|
||||||
|
// 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
|
||||||
|
}
|
||||||
391
zddc/internal/handler/history_test.go
Normal file
391
zddc/internal/handler/history_test.go
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ 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"
|
||||||
|
|
@ -165,18 +166,216 @@ 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
|
||||||
}
|
}
|
||||||
if err := zddc.WriteAtomic(yamlAbs, yamlBytes); err != nil {
|
// Route through WriteWithHistory so audit fields (created_*,
|
||||||
auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, len(yamlBytes), err)
|
// updated_*, revision=1) are stamped uniformly with the PUT
|
||||||
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
|
// path. No prior file exists, so the history-write branch is a
|
||||||
|
// 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(yamlBytes), nil)
|
auditFile(r, "ssr-create", rowURL, http.StatusCreated, len(finalBody), 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.
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,12 @@ 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.
|
||||||
|
|
@ -93,6 +99,16 @@ 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:
|
||||||
|
|
@ -195,14 +211,14 @@ func classifyDefaultSpec(rel string) []byte {
|
||||||
case "table.yaml":
|
case "table.yaml":
|
||||||
return embeddedDefaultProjectMdlTable
|
return embeddedDefaultProjectMdlTable
|
||||||
case "form.yaml":
|
case "form.yaml":
|
||||||
return embeddedDefaultMdlForm
|
return embeddedDefaultProjectMdlForm
|
||||||
}
|
}
|
||||||
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 embeddedDefaultRskForm
|
return embeddedDefaultProjectRskForm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1253,7 +1253,11 @@ 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). */
|
||||||
.zddc-table__row--dirty td:first-child { box-shadow: inset 3px 0 0 var(--color-info, #4a90e2); }
|
/* Dirty row gets a wider swatch (4px → easier to see at a glance) AND
|
||||||
|
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)); }
|
||||||
|
|
@ -1511,7 +1515,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 12:37:53 · 847e082-dirty</span></span>
|
<span class="build-timestamp">v0.0.18</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -1536,6 +1540,7 @@ 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>
|
||||||
|
|
@ -4105,6 +4110,7 @@ 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) {
|
||||||
|
|
@ -4114,6 +4120,17 @@ 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) {
|
||||||
|
|
@ -5333,13 +5350,30 @@ 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, '');
|
||||||
row.data = merged;
|
// For record-typed writes the server echoes the stamped
|
||||||
|
// 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' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5350,6 +5384,7 @@ 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' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5580,6 +5615,51 @@ 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.
|
||||||
|
|
@ -5592,7 +5672,11 @@ 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);
|
||||||
|
|
@ -5753,9 +5837,29 @@ 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';
|
||||||
return [
|
items.push({
|
||||||
{
|
|
||||||
label: label,
|
label: label,
|
||||||
icon: '🗑',
|
icon: '🗑',
|
||||||
danger: true,
|
danger: true,
|
||||||
|
|
@ -5764,8 +5868,9 @@ 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) {
|
||||||
|
|
@ -6307,6 +6412,7 @@ 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
|
||||||
|
|
@ -6315,6 +6421,50 @@ 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
|
||||||
|
|
@ -6431,6 +6581,11 @@ 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 /
|
||||||
|
|
@ -6614,7 +6769,16 @@ 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']) || '';
|
||||||
const readonly = !!(ui && ui['ui:readonly']);
|
// readonly is honored from either source: an explicit UI override
|
||||||
|
// (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;
|
||||||
|
|
@ -6660,17 +6824,22 @@ 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: String(opt) });
|
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: codeStr });
|
||||||
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(' ' + String(opt)));
|
lbl.appendChild(document.createTextNode(' ' + displayText));
|
||||||
input.appendChild(lbl);
|
input.appendChild(lbl);
|
||||||
});
|
});
|
||||||
read = function () {
|
read = function () {
|
||||||
|
|
@ -6683,7 +6852,12 @@ 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 o = u.h('option', { value: String(opt) }, String(opt));
|
const codeStr = 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;
|
||||||
}
|
}
|
||||||
|
|
@ -6754,6 +6928,12 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,25 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,60 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,17 @@ 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
|
||||||
|
|
@ -153,6 +164,16 @@ 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]
|
||||||
|
|
@ -160,6 +181,20 @@ 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
|
||||||
|
|
|
||||||
215
zddc/internal/zddc/field_codes.go
Normal file
215
zddc/internal/zddc/field_codes.go
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -343,6 +343,43 @@ 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).
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,30 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,44 @@ 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue