A new HTML tool — browse — that lists the contents of any directory.
Designed for ZDDC archives but no ZDDC-specific filtering; just a
straight folder browser with expand/collapse, sort, and name filter.
Modes (auto-detected at page load):
- Online: when served by zddc-server at a folder URL, queries
the same URL with Accept: application/json to load the listing
and renders it. Auto-served as the default at any directory
under ZDDC_ROOT without an index.html (replacing the previous
minimal-HTML stub from directory.go).
- Local: 'Select Directory' button uses FileSystemAccessAPI to
pick any folder on disk; works in Chromium-based browsers.
Features (Phase 1 — what's in this commit):
- Tree view with lazy-loaded folders (children fetched on first
expand).
- Sort by name / size / extension / date (column header click).
- Filter by name substring (toolbar input).
- File click opens in a new tab — for server-backed pages,
routes through zddc-server's normal handler so .archive
redirects + apps cascade overrides + ACL all apply.
Phase 2 deferred:
- ZIP files inline expansion (treat archive entries as virtual
children).
- File preview popup (reuse shared/preview-lib.js).
- Extension multi-select filter.
Wiring:
- browse/ added to top-level ./build's per-tool list, embed
block, versions.txt, and the lockstep release commit + tag set.
All seven tools (archive, transmittal, classifier, mdedit,
landing, form, browse) advance together on stable cuts.
- shared/build-lib.sh: browse added to ZDDC_RELEASE_TOOLS and
verify_channel_links's per-tool loop.
- zddc/internal/apps/embed.go: //go:embed browse.html +
EmbeddedBytes("browse") case.
- zddc/internal/apps/availability.go: browse available at every
directory (same as archive).
- zddc/internal/apps/handler.go: MatchAppHTML routes
/<dir>/browse.html → 'browse'.
- zddc/internal/handler/directory.go: when a directory request
arrives with Accept: text/html and no index.html exists,
serve the embedded browse.html bytes (with a JSON-fallback
if the embedded slot is empty during bootstrap).
The zddc-server-v0.0.9 (and sibling) tags previously pointed at a
commit whose embedded versions.txt + tool HTMLs still carried
alpha-dirty labels — the cut process regenerated these in the
working tree but never folded them into the tagged commit. The
binary built from that tag (used by tnd-zddc-chart's prod
Dockerfile) embedded the alpha labels.
This commit folds the stable-labeled artifacts in. The seven
v0.0.9 tags are force-moved to point here so future binary builds
from `ZDDC_REF=stable` get clean stable bytes baked in. The
old commit (a02a26d) remains in history; just no tag references
it anymore.
Sustainable fix to ./build's release flow (commit before tag,
skip embedded mutation on plain dev/alpha cuts) is a separate
follow-up — this commit only fixes the in-flight state.
Schema-driven form renderer plus zddc-server endpoints that turn any
<name>.form.yaml into a working data-collection form at <path>/<name>.form.html.
Submissions land in <path>/<name>/<YYYY-MM-DD>-<email-sanitized>.yaml,
ACL-gated by the existing .zddc cascade. The form posts back to its own URL;
the server strips ".html" and routes by what's underneath, so create and
update use the same client-side code path.
Form spec dialect: JSON Schema 2020-12 + RJSF-style ui:* hints, written in
YAML. Chosen for LLM authorability — it's the canonical structured-output
target for OpenAI/Anthropic, and the ui:* convention is the most-trained UI
hint vocabulary. Supported subset for v0: type (string/number/integer/boolean/
array/object), enum, min/max, minLength/maxLength, required, additionalProperties:
false, properties, items, format (date, email). Round-trip mode is form-as-truth:
submission YAML is regenerated each save, comments are not preserved (the v1
file-as-truth mode for hand-edited files like .zddc itself is deferred).
New components:
* form/ — sixth single-file HTML tool, vanilla JS renderer (~760 LoC)
* zddc/internal/jsonschema/ — focused JSON Schema validator covering only
the v0 keyword subset. Match-implementation-cost-to-surface-used: a full
library brings 70%+ surface we don't use; revisit when v1 adds $ref +
oneOf + if/then/else.
* zddc/internal/handler/formhandler.go — RecognizeFormRequest / ServeForm,
capability-URL re-edit, atomic submission writes via the new
zddc.WriteAtomic helper extracted from writer.go.
* dispatch() in zddc-server/main.go now intercepts *.form.html and
*.yaml.html before the static-file path; spec existence is the trigger.
Build pipeline: form joins ZDDC_RELEASE_TOOLS in lockstep, gets its own
embedded copy in handler/form.html (separate from the apps cascade —
the form renderer is fixed, not subject to per-folder version overrides).
Tests: 5 new Playwright specs (form-safety) + 14 new Go tests across the
validator and handler. All 172 Playwright tests + 10 Go packages green.
End-to-end manual verification: GET empty → POST 201 + capability URL →
GET re-edit (pre-filled) → POST update → 200, raw YAML browsable, ACL
deny → 403.
Docs: form/ section added to AGENTS.md and ARCHITECTURE.md. AGENTS.md
also documents the implementation-vs-dependency policy. CLAUDE.md repo-shape
list extended.
Deferred (v1+): .zddc editor migration onto this system, file-as-truth
lossless YAML round-trip, ui:show-when conditional visibility, oneOf/anyOf,
apps-cascade preview hook, cascade-fetched form definitions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrates from in-repo orphan `website` branch + LFS to a two-repo +
deploy-host model so source editing is fully decoupled from live state.
- Source code stays here (codeberg.org/VARASYS/ZDDC).
- Hand-edited website content moves to a separate Codeberg repo
(codeberg.org/VARASYS/ZDDC-website, cloned at ~/src/zddc-website/).
- Live site is /srv/zddc/ on the deploy host (Caddy bind-mount),
populated by ./deploy from this repo's dist/release-output/ plus
~/src/zddc-website/.
- Releases are no longer in any git history — reproducible from
<tool>-vX.Y.Z tags via `./build release X.Y.Z`. No LFS, no
Codeberg release assets.
Build/deploy split:
- ./build (no arg) is source-only; nothing in dist/release-output/
or /srv/zddc/ is touched.
- ./build alpha|beta|release seeds dist/release-output/ from
/srv/zddc/releases/ (preserving symlinks), then mutates the
channel(s) being cut on top. The bundle is always a complete
intended-live snapshot, so the verifier sees a complete world
and ./deploy --releases (rsync --delete-after) replaces live
state cleanly.
- New ./deploy wraps the rsync flow with --content / --releases
subcommands.
Docs updated to reflect the new model: CLAUDE.md, AGENTS.md,
ARCHITECTURE.md, zddc/README.md, README.md, .gitignore, shared/
build-lib.sh comments, deprecated zddc/release.sh message.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reverts the prior CLI simplification. ./build (no arg) now does source
work only — tool dist/ + cross-compiled zddc-server binaries — and
leaves the website worktree alone. Channel/release cuts are explicit:
./build dev build (source only, no deploy)
./build alpha cut alpha (cascades nothing)
./build beta cut beta (cascades alpha → beta)
./build release [X.Y.Z] cut stable (cascades all)
Rationale: editing source shouldn't have a side-effect on the live
site. The website worktree at ~/src/zddc-website/ is what Caddy serves
in real time, so any write to it is a deploy. Treating dev iteration
as alpha-publish was confusing — the user wanted source builds and
deploys to be distinct verbs.
Mechanically: a `dev` (default) branch is added to the case statement;
the post-build matrix-index regen + channel-link verifier are
conditional on RELEASE_CHANNEL being set; dev builds skip them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renames build.sh → build and replaces the --release flag form with
subcommands:
./build cut alpha (default; active dev iteration)
./build beta cut beta (cascades alpha → beta)
./build release cut stable (coordinated next version)
./build release X.Y.Z cut stable at explicit version
./build help
The contract shift: there's no longer a "plain dev build that doesn't
touch channels" at the top level. Every full-stack build is a publish
action — running ./build IS active dev iteration, which is what alpha
already meant. To iterate on one tool without writing to the website
worktree, use the per-tool sh tool/build.sh (unchanged).
Output continues to land in ${ZDDC_DEPLOY_RELEASES_DIR:-$HOME/src/zddc-website/releases}
and nothing is pushed automatically. Commit + push the website branch
yourself when you want to publish. Stable cuts still tag locally on
main; tags push separately too.
Behind the scenes: the export of ZDDC_DEPLOY_RELEASES_DIR is moved
above the per-tool build.sh invocations so children inherit it. The
prior "if RELEASE_CHANNEL else write_zddc_server_stubs_all" branch is
collapsed since RELEASE_CHANNEL is always set under the new CLI.
Docs (CLAUDE.md, AGENTS.md, ARCHITECTURE.md, zddc/README.md) updated
to reference ./build everywhere; the per-tool sh tool/build.sh refs
stay (they're a separate, narrower entry point).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves website source + release artifacts off `main` and into a new
orphan branch named `website` in this same Codeberg repo. A `git worktree`
of that branch — typically at ~/src/zddc-website/ — is what the system
Caddy now bind-mounts and serves at zddc.varasys.io. Decoupling source
from the live site means editing source can no longer accidentally
affect what's published.
Layout going forward:
- ~/src/zddc/ — main worktree (this branch, source only).
- ~/src/zddc-website/ — git worktree of the `website` branch:
hand-edited content + LFS-tracked release
artifacts (server binaries) + regular-git
HTML tool releases + symlinks.
- Caddy bind-mount swapped: ~/src/zddc/website → ~/src/zddc-website
(quadlet at /etc/containers/systemd/caddy.container, restarted).
Build pipeline now writes releases to
${ZDDC_DEPLOY_RELEASES_DIR:-$HOME/src/zddc-website/releases}.
- build.sh: RELEASES_DIR points at the env var
- shared/build-lib.sh: promote_release honors the env var, falls
back to the legacy in-repo path so any
standalone single-tool release on a checkout
that still has website/ keeps working
- freshen-channel: passes ZDDC_DEPLOY_RELEASES_DIR through to
the worktree-based build
Docs (CLAUDE.md, AGENTS.md, ARCHITECTURE.md, .gitignore) updated for
the new layout. The 51 MB of website/ blobs stays in main's history
(no force-push); over time Codeberg's GC will pack them down.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructures the version picker on the releases page so channel
mirrors are first-class, selectable options:
Channels (mutable URLs)
stable — currently v0.0.8 ← default selection
beta — tracks stable
alpha — tracks stable
Pinned versions (immutable URLs)
v0.0.8
v0.0.2
v0.0.1
Picking "stable" now rewires every download link on the page to the
*_stable.html (HTML tools) or zddc-server_stable_<plat> (binary)
channel-mirror URL — the kind a user wants to copy + bookmark for a
"latest stable" reference. Same for beta and alpha. Picking a pinned
vX.Y.Z still rewires to immutable per-version URLs.
Removed the separate "Or pick a channel" chip-pill row that previously
sat under the picker. The dropdown is now the single control; chips
duplicated functionality and added visual noise. The .channel-chips
CSS rules in website/css/style.css come out with them.
Static defaults (without JS) now use the stable channel mirror URLs
too, so both copy-from-source and JS-rewire produce the same outcome
for users who want stable. The page works end-to-end with JS off.
Embedded snapshots refreshed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues from one session:
* File tree: ZDDC-conforming filenames render as a single line
even though the JS already produced two-div markup (filename-main +
filename-secondary). Cause: .tree-row__label was display:flex
(row-direction), so the two divs laid out side-by-side. Fix: wrap
each label's text in a new .tree-row__name span styled
flex-direction:column. Both file and folder code paths use the
same wrapper now; non-ZDDC entries collapse to a single
.filename-main line so typography stays consistent across the tree.
Tested by injecting a ZDDC filename into a mock directory and
asserting filename-secondary's bounding-box top is below
filename-main's bottom.
* Toast UI Editor was unreadable in dark mode. Toast UI ships with
light-only chrome; its .toastui-editor-md-container has color #222
on a transparent bg, so when mdedit's dark theme rendered the
surrounding pane in #1e1e1e the editor text fell on near-black
background → effectively invisible. Fix: add CSS overrides in
mdedit/css/editor.css that target the editor's load-bearing
surfaces (md-container, md-preview, ww-container, ProseMirror,
toolbar, mode-switch tabs, popups) and apply var(--bg) /
var(--text). Toolbar icons get a filter:invert(0.85) hue-rotate
to flip the sprite-baked dark glyphs. Both manual override
(data-theme="dark") and OS-pref auto fallback (prefers-color-scheme)
are covered. Tested by computing contrast ratios on every editor
surface in dark mode — all came in at 10:1+ (well above WCAG AA's
4.5:1).
Embedded snapshots refreshed to current main HEAD's dev build label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A machine-only HTTP endpoint that returns 200 if the request's
X-Auth-Request-Email is in the root .zddc admins: list, 403 otherwise.
No body, no redirect — pure authorization decision intended to be
polled by an upstream proxy's forward_auth directive.
The motivating use case is gating /devshell/* (code-server) in the
dev-shell pod on root-admin status before the request ever reaches
code-server, which has no built-in ACL of its own. zddc-server's
own routes keep the existing .zddc cascade ACL and don't go through
this endpoint.
Reuses zddc.IsAdmin (one cached map lookup) so the check is cheap
enough to call on every request. Edits to /srv/.zddc propagate via
the existing fsnotify watcher's policy-cache invalidation.
Tests cover empty email, non-admin, admin, and the bootstrap state
where no root .zddc exists (deny everyone — the safe default).
Docs: zddc/README.md "Forward-auth target for upstream proxies"
section + AGENTS.md notes bullet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First lockstep cut. All six tools (5 HTML + zddc-server) at the same
version. zddc-server jumped from 0.0.7 → 0.0.8; HTML tools jumped from
0.0.2 → 0.0.8 (the coordinated next-stable rule picks max-tag + 1, so
HTML tools skip ahead in lockstep with the binary).
Tags created:
archive-v0.0.8
transmittal-v0.0.8
classifier-v0.0.8
mdedit-v0.0.8
landing-v0.0.8
zddc-server-v0.0.8
All tags point at 9fce18c (the source-state commit).
Artifacts under website/releases/:
- 5 per-version HTML tool files (immutable real bytes)
- 4 zddc-server binaries × 5 cascade levels (per-version + 4 symlink
levels) per platform = 20 binary entries per platform × 4 platforms
- 6 channel/per-version stub HTML pages for zddc-server (matrix-cell
download fan-out)
- All HTML tool channel mirrors updated to track v0.0.8
- Matrix index regenerated with v0.0.8 as current stable
channel-link verification: 30 link(s) ok
First time the verifier runs in non-bootstrap mode (zddc-server stable
chain anchored).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four entangled change-sets from one session, committed together because
their file-level overlap (build.sh, docs, embedded/, watcher.go, …) makes
post-hoc separation noisy:
* fix(archive): nested-party + folder-type cascade
transmittalIsUnderVisibleParty short-circuited on the first matched
party segment, only checking the immediately-next segment for a
folder-type marker. Paths like BM/sub/Issued/<txn> bypassed the Issued
toggle entirely. Replaced with isUnderHiddenFolderType (full-path) +
any-segment party match. Eight new Playwright cases pin the contract
in tests/archive-cascade.spec.js.
* refactor(zddc-server): scope .archive index by project
archive.Index now buckets by top-level segment
(.ByProject[<project>].ByTracking[<tracking>]). Resolve and AllEntries
take a project parameter; handler extracts it from contextPath's first
segment. /.archive/ at root returns 404 — stable refs must be
project-rooted. Within-project (tracking, rev) collisions emit a WARN
with both paths. Cross-project tracking-number duplicates no longer
collide.
* perf(zddc-server): lazy-load expensive bits of the profile page
serveProfilePage now ships a minimal shell: Email, EmailHeader,
IsSuperAdmin (root .zddc only). Visible projects + admin subtrees +
editable scaffolds populate client-side via /.profile/access. Subtree-
admin scaffolds live in <template id="tmpl-subtree-admin">; pure
non-admins receive no live admin form. ScanZddcFiles now memoized,
invalidated on .zddc events by the watcher and writer helpers.
* feat: lockstep release + redesigned releases page
sh build.sh --release [version|alpha|beta] is the canonical lockstep
cut: every tool (5 HTML + zddc-server) bumps to the same coordinated
version. zddc-server binaries now committed under website/releases/
with the same cascade chain as HTML tools (no more Codeberg release-
asset publication). zddc/release.sh deprecated (kept as a guard);
shared/publish-codeberg-release.sh removed.
Releases page redesigned as an action-first install guide: hero +
version dropdown that rewires every download link, channel chips for
always-visible alpha/beta access (state-aware labels: "tracks stable"
vs "active dev"), Path A (zddc-server with platform auto-detect from
UA), Path B (5 standalone tool HTMLs), version-pinning empowerment
narrative (drop-a-copy vs .zddc apps: cascade), channels explainer.
Channel-link verifier asserts every <tool>_{stable,beta,alpha}.html
resolves at the end of every build. Bootstrap-friendly: zddc-server
artifact checks skip until the first lockstep cut anchors the chain.
Tests: 167 Playwright + all Go packages green.
Docs: CLAUDE.md, AGENTS.md, ARCHITECTURE.md, zddc/README.md updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds command-line flags to zddc-server alongside the existing env vars.
Each setting can be set via --<flag-name> or ZDDC_<NAME>; the flag wins
on conflict, the env var wins over the hard-coded default.
--root / ZDDC_ROOT (now defaults to CWD if both unset)
--addr / ZDDC_ADDR (:8443)
--tls-cert / ZDDC_TLS_CERT ("none" / empty / path)
--tls-key / ZDDC_TLS_KEY
--log-level / ZDDC_LOG_LEVEL (info)
--index-path / ZDDC_INDEX_PATH (.archive)
--email-header / ZDDC_EMAIL_HEADER (X-Auth-Request-Email)
--cors-origin / ZDDC_CORS_ORIGIN (https://zddc.varasys.io; "" disables)
--insecure-direct / ZDDC_INSECURE_DIRECT (false)
--help (prints flag list to stderr, exits 0)
--version (prints binary + embedded tool versions, exits 0)
So an operator can `cd /srv/zddc && zddc-server` with zero config — the
served root defaults to the current directory, and TLS defaults to a
self-signed cert. config.Load now takes []string (test-friendly: nil
skips flag parsing entirely; tests pass an empty slice for env-only
loads).
Adds a `version` package-level var in main.go injected at link time via
`-ldflags="-X main.version=..."`. The build.sh runs git describe against
zddc-server-v* tags; for in-flight commits between releases it produces
e.g. zddc-server-v0.0.7-19-gadb6904-dirty.
Adds an embedded versions manifest:
- Each tool's compute_build_label (in shared/build-lib.sh) writes a
sidecar <tool>.label to $BUILD_LABELS_DIR if that env var is set.
- Top-level build.sh sets BUILD_LABELS_DIR before running each tool's
build, then assembles zddc/internal/apps/embedded/versions.txt as
one `<app>=<build label>` line per app.
- apps.EmbeddedVersions() loads the manifest at runtime.
- main.go logs a compact summary on every startup; --version dumps
the full per-app label.
Removes the old cfg.BuildVersion field — the X-ZDDC-Source: embedded
header now uses the package-level main.version directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the form-based .zddc editor at /.profile/zddc/edit?path=<dir>
with an Apps section between Admins and the Effective chain.
The section is a six-row table — default plus the five canonical apps —
with one text input per row. Each row's right column shows a server-
rendered "Resolves to" preview computed by walking the cascade through
this directory and applying default + per-app composition. The preview
displays the final URL, "embedded (build-time default)", or "local file:
<path>" so operators see exactly what will be served.
Help text covers the full spec syntax (channel/version/URL/path forms,
:channel shorthand, default key) plus the ?v= per-request override and
its cache-only security constraint.
Permission gating is unchanged: existing CanEditZddc() strict-ancestor
rule applies — subtree admins cannot edit the file that grants their
own authority. Field-level errors land inline next to the input, just
like the existing ACL/admins fields.
POST handler (internal/handler/zddchandler.go) accepts a new Apps map
in the JSON write request, validates via the existing zddc.ValidateFile
flow (which now enforces apps.<name> spec syntax), and writes
atomically through the unchanged zddc.WriteFile path.
Three new tests: round-trip apps including the default key, per-field
validation error returns, and editor renders the apps section with
existing .zddc values pre-filled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds internal/apps/ package serving the five tool HTMLs at virtual paths
based on the surrounding folder name convention:
archive every directory (multi-project, project, archive, vendor)
classifier any Incoming/Working/Staging directory and subtree
mdedit any Working directory and subtree
transmittal any Staging directory and subtree
landing only at deployment root
The current-stable build of every tool is //go:embed'd into the binary
at compile time — that's the default with zero config. Operators
override per-directory via .zddc apps: entries; closer-to-leaf wins.
Spec syntax (in any apps: value):
stable / beta / alpha / :stable channel
v0.0.4 / v0.0 / v0 / :v0.0.4 version
https://my-mirror/releases URL prefix only
https://my-mirror/releases:beta URL prefix + channel
https://my-fork/archive.html terminal full URL
./local.html / /abs/path.html terminal local path
The special apps.default key provides a baseline URL prefix and channel
inherited by any app not overridden per-name. Per-axis cascade: a deeper
.zddc can override the URL, the channel, or both.
Cascade walks root→leaf; default applies first at each level, then the
per-app entry. Terminal sources (paths and full .html URLs) short-circuit
composition; deeper non-terminal entries override parent terminals.
URL sources fetch once on first request and cache forever in
<ZDDC_ROOT>/_app/<host>/<path> — different upstreams with the same
filename stay distinct. No background refresh, no SHA-256 verification:
operators delete the cache file to force a refetch. Concurrent misses
for the same source dedupe via a 30-line hand-rolled singleflight.
Per-request override: any user can append ?v=<spec> to a tool URL
(e.g. ?v=beta, ?v=v0.0.4, ?v=:alpha, ?v=https://mirror/releases:beta)
to ask for a different build for one request. Security: ?v= serves
ONLY versions already in the cache (cache miss returns 404; path
sources are rejected outright with 400). Users cannot trigger
arbitrary upstream fetches via crafted URLs.
Failed URL fetches (network down, 5xx) fall back to embedded with a
one-time WARN log. The X-ZDDC-Source response header reports what
served: fetch:URL / cache:URL / path:/abs / embedded:<app>@<build>.
Wire-in (cmd/zddc-server/main.go): dispatch routes <dir>/<app>.html
through apps.MatchAppHTML + AppAvailableAt + apps.Server.Serve when
no real file exists. Direct URL access to /_app/... is blocked at
the dispatch layer — cached files must go through the apps resolver
so they get correct Content-Type and ACL gating.
Schema (internal/zddc/file.go): ZddcFile gains Apps map[string]string
for cascade overrides. Validator (internal/zddc/validate.go) accepts
the special "default" key alongside the five canonical app names and
all spec forms.
Removes ZDDC_APPS_* env vars (no admin UI, no refresh interval, no
upstream allow-list — the simpler model has fewer knobs).
40+ unit tests across the new package: parser shapes, cascade
resolution with default+per-app interactions, terminal short-circuit
semantics, ?v= cache-only enforcement, embedded fallback, atomic
cache writes, singleflight dedup. Plus end-to-end dispatch tests in
cmd/zddc-server/main_test.go.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The middleware chain in main.go was:
AccessLogMiddleware ( CORSMiddleware ( ACLMiddleware ( dispatch ) ) )
ACLMiddleware extracts the user email from the configured header and
stores it in the request context via r.WithContext. But Go's context
propagates DOWN the chain (to handlers further in) — not back UP. The
new context-bearing request only exists inside the call to
next.ServeHTTP; once that returns, the outer middleware still has the
ORIGINAL request without the email. So AccessLogMiddleware's
EmailFromContext(r) call (which runs after next.ServeHTTP returns to
log the request) read from the original context and got an empty
string, falling through to "anonymous".
The /.profile/ page worked correctly because it reads the email
directly inside the handler — at that depth the context-bearing
request is the one in scope.
Fix: invert the chain so ACL is OUTERMOST.
ACLMiddleware ( AccessLogMiddleware ( CORSMiddleware ( dispatch ) ) )
Now ACL extracts the email and the new request flows down through
AccessLog (which sees the email-bearing context), CORS, and dispatch.
Add three regression tests in middleware_test.go:
TestAccessLogReadsEmailFromACLContext
The fix: with ACL outer, AccessLog logs email=alice@example.com
when X-Auth-Request-Email is set.
TestAccessLogAnonymousWhenNoEmail
The unchanged path: no header → email=anonymous (correct fallback).
TestAccessLogOuterDoesNotSeeInnerContext
Locks down Go's actual context-propagation behavior. Builds the
INVERTED (buggy) chain and asserts that AccessLog (outer) does NOT
see the email ACL (inner) set. If this ever fails, Go's context
propagation has changed in a way that lets inner-set context flow
upward — which would mean the reordering fix could be reverted.
All zddc-server tests pass via `go test ./...` (run in podman against
golang:1.24-alpine since this dev host doesn't have Go installed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the super-admin-only /.admin/ surface with a public-by-default
/.profile/ page that layers admin tools server-side based on the
caller's effective access:
- Universal (everyone, anonymous included): identity card, effective
access summary, theme picker, localStorage utilities (export / import
/ clear, landing-presets viewer).
- Subtree admins additionally see: editable .zddc files list (linking
to the existing form-based editor) and a "Create new project folder"
form.
- Super-admins additionally see: server config, log viewer, whoami
headers (the old /.admin/ JSON endpoints, repointed under /.profile/).
Project creation is gated on CanEditZddc(newDir) — the same strict-
ancestor rule that already governs .zddc writes — so no new authority
concept is introduced. ValidateProjectName mirrors the existing
reserved-prefix policy (no leading '.' or '_', no path separators).
/.admin/* is hard-cut: no redirect shim. Old URLs fall through to the
existing dot-prefix guard and 404. Custom CSS file rename: prefer
<root>/.profile.css, fall back to legacy <root>/.admin.css.
Per-resource 404 leakage gates preserved on whoami / config / logs /
zddc / projects so non-admin callers cannot detect the existence of
admin-only sub-resources.
Tree-wide gofmt -w applied as a side-effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generalize the admin model from "single root super-admin" to a
delegated chain: a `<dir>/.zddc/admins` list grants admin authority
for that subtree, with a strict-ancestor rule preventing
self-elevation (you cannot edit the .zddc that grants your own
authority — only files strictly below it).
Add a guided server-rendered editor at /.admin/zddc/edit?path=<dir>
so subtree admins can manage their fiefdoms without filesystem
access. JSON API at /.admin/zddc covers GET (file + effective chain
+ can_edit), POST (atomic write + cache invalidation), DELETE,
plus a /tree endpoint listing every .zddc visible to the caller.
Optional theming via <root>/.admin.css.
Validation: glob syntax check, root-self-demotion rejection,
reserved-prefix path guard, YAML round-trip sanity. Writes are
atomic (temp file + fsync + rename) and invalidate the policy
cache.
Also includes the prior in-flight `Title` field on ProjectInfo
so per-project .zddc titles surface on the landing-page picker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .archive virtual directory now emits both <tracking>.html (highest
base rev) and <tracking>_<rev>.html (each specific base rev) so HTML
documents can deep-link to a known revision and have it resolve to the
first chronologically received copy. Modifier files (<rev>+C1 etc.) stay
reachable via the resolver but aren't surfaced in the listing.
.archive at any folder depth serves the same global index — the depth
exists so offline HTML can use ../.archive/<tracking>.html and let the
browser resolve it before the request reaches the server. The earlier
attempt at scoping listings to the contextPath subtree was wrong; gating
is purely by ACL: contextPath gates the listing endpoint, and each
entry's resolved file gets its own per-target ACL check (404 on denial,
not 403, so cross-subtree existence isn't disclosed).
Adds the first tests for the previously untested archive package, plus
end-to-end ACL coverage for the handler (cascade direction, default-deny
once any .zddc exists, anonymous denied under allow:[\"*@…\"], stable
Location across contextPaths).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ACLMiddleware now slog.Debug's the configured email-header name, the
observed value at that name, and the full r.Header map on every request.
Off at the default INFO log level; enable per-pod with ZDDC_LOG_LEVEL=debug.
Motivated by debugging the X-Auth-Request-Email passthrough chain — when
access logs show email=anonymous, /.admin/whoami is unreachable (the
admin gate requires a non-empty email, which is the chicken-and-egg).
The debug log line dumps headers without the gate, so an operator can
identify whichever header name the upstream proxy is actually setting
(X-Forwarded-User, X-Forwarded-Email, Remote-User, X-Authentik-Email,
etc.) and adjust ZDDC_EMAIL_HEADER accordingly.
The debug-level dump captures auth tokens and cookies along with
everything else; safe in dev clusters, not appropriate for production
unless the operator is comfortable with the trade-off. README documents
the trade-off in the Admin Debug Page section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Listings now filter both '.' and '_' prefixes:
- '.' entries: excluded from listings AND 404 on direct HTTP access
(existing behavior). For invisible side-state like .devshell.
- '_' entries: excluded from listings only — direct URL access still
works. For operator scaffolding like install.zip's _template/
directory of bootstrap stubs that should be reachable but should
not appear in the project picker.
Filter applied at both listing entry points: ServeProjectList (the
project picker JSON at GET / Accept: application/json) and the generic
listing/FromDirEntries (used by ServeDirectory for sub-directory
browse listings).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three improvements bundled because they all ship as zddc-server v0.0.2:
* /.admin/ debug dashboard with /whoami, /config, /logs sub-routes.
Authorization via a top-level `admins:` glob list in <ZDDC_ROOT>/.zddc
(root-only — subdir entries deliberately ignored to prevent privilege
escalation via subtree write access). Non-admin requests get 404 so the
page is invisible. Recent logs surface via a 500-entry slog ring buffer
teed off the existing TextHandler. Lets operators debug without
kubectl exec.
* Default ZDDC_EMAIL_HEADER changes from `X-Email` to
`X-Auth-Request-Email` — the oauth2-proxy / nginx auth-request
convention that the TND helm chart already sets explicitly.
Operators who set the env var explicitly are unaffected; deployments
relying on the previous default need to set ZDDC_EMAIL_HEADER=X-Email
or update their proxy.
* dispatch() rejects any URL whose segments contain a dot prefix other
than the recognized virtual prefixes (.admin, cfg.IndexPath /
.archive). Matches the existing listing-pipeline filter so hidden
subtrees on the served PVC (e.g. /srv/.devshell — used by the
in-cluster dev-shell for persistent home-dir state) become
unreachable via direct HTTP fetch, not just hidden in listings.
Refreshes the X-Email reference in website/index.html accordingly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ZDDC — Zero Day Document Control. A file-naming convention plus five
single-file HTML tools (archive, transmittal, classifier, mdedit,
landing) and an optional Go HTTP server (zddc-server) with ACL and a
virtual archive index. Self-contained, offline-capable, dependency-free.
See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the
build/release/architecture detail, bootstrap/README.md for the
two-level deployment install pattern, and zddc/README.md for the
HTTP server.