ZDDC/zddc
ZDDC 67f794e6d0 refactor: rename channel 'latest' to 'stable' across all artifacts
The 'latest' label for the current-stable channel was inconsistent
with the channel set we use elsewhere (alpha / beta / stable). Rename
to 'stable' so URLs, file names, zip names, and image tags all line
up with the channel terminology used in the bootstrap, AGENTS.md
discipline rules, and chart consumers.

File / artifact renames
- website/releases/<tool>_latest.html → <tool>_stable.html (5 files)
- website/track-latest.zip            → track-stable.zip
- shared/build-lib.sh: promote_release writes/refreshes _stable.html
- bootstrap/level{1,2}.html.tmpl: channels map drops 'latest', keeps
  'stable' as the canonical name. ?v=stable is now the explicit way
  to switch to current-stable for one request (alongside ?v=alpha,
  ?v=beta, and ?v=X.Y.Z).
- build.sh: install.zip sources from <tool>_stable.html; emits
  track-stable.zip instead of track-latest.zip.

Container image (.woodpecker.yml rewritten)
- Tag publishing now cascades:
    zddc-server-vX.Y.Z              → :X.Y.Z, :stable, :beta, :alpha, :latest
    zddc-server-vX.Y.Z-beta.N       → :X.Y.Z-beta.N, :beta, :alpha
    zddc-server-vX.Y.Z-alpha.N      → :X.Y.Z-alpha.N, :alpha
- :stable, :beta, :alpha are now first-class channel pointers; chart
  consumers (e.g. tnd-zddc-chart) can FROM :beta for dev and FROM
  :stable for prod.
- :latest kept as an alias for :stable per Docker convention.

Documentation sweep
- AGENTS.md, ARCHITECTURE.md, CLAUDE.md, README.md
- bootstrap/README.md, zddc/README.md
- website/index.html, website/zddc-server.html
- transmittal/template.html, transmittal/README.md
all updated to reference _stable.html / track-stable.zip / the
'stable' channel name. ARCHITECTURE.md's manual freshen example
points at ./freshen-channel instead of the old git-checkout snippet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:30:24 -05:00
..
cmd/zddc-server Initial commit 2026-04-27 11:05:47 -05:00
internal Initial commit 2026-04-27 11:05:47 -05:00
Containerfile feat(zddc-server): publishable runtime image + Codeberg CI pipeline 2026-04-27 14:46:59 -05:00
go.mod Initial commit 2026-04-27 11:05:47 -05:00
go.sum Initial commit 2026-04-27 11:05:47 -05:00
podman-compose.yaml Initial commit 2026-04-27 11:05:47 -05:00
README.md refactor: rename channel 'latest' to 'stable' across all artifacts 2026-04-28 09:30:24 -05:00

zddc-server

A purpose-built HTTPS file server for ZDDC document archives. Designed to replace caddy file-server --browse with features specific to ZDDC workflows.

Features

  • High-performance static file serving — ETag, conditional GET, Cache-Control
  • Cascading .zddc ACL — email-based allow/deny lists evaluated bottom-up from requested directory to root
  • Caddy-compatible JSON listings — the Archive Browser works without modification
  • Virtual .archive index — resolve the earliest revision of any tracked document by URL
  • Filesystem watcher — archive index updates automatically when files change
  • Flexible TLS modes — self-signed, real certificates, or plain HTTP
  • Podman-native — multi-stage build, non-root runtime, SELinux-compatible volumes

Quick Start

# Build the container image
podman build -t zddc-server .

# Run against your archive root
podman run --rm \
  -v /srv/archive:/data:z \
  -e ZDDC_ROOT=/data \
  -p 8443:8443 \
  zddc-server

Or with podman-compose:

ZDDC_DATA_DIR=/srv/archive podman-compose up --build

Docker users: Replace podman with docker and podman-compose with docker-compose. Remove the :z volume suffix (that is a SELinux/podman convention).

Environment Variables

Variable Default Description
ZDDC_ROOT (required) Absolute path to the served file tree
ZDDC_ADDR :8443 Bind address (host:port)
ZDDC_TLS_CERT (empty) Path to PEM certificate file. none = plain HTTP (no TLS); empty = generate self-signed
ZDDC_TLS_KEY (empty) Path to PEM private key file. Required when ZDDC_TLS_CERT is a file path; ignored otherwise
ZDDC_LOG_LEVEL info Log level: debug, info, warn, error
ZDDC_INDEX_PATH .archive URL path segment name for the virtual archive index
ZDDC_EMAIL_HEADER X-Email HTTP request header containing the authenticated user's email
ZDDC_CORS_ORIGIN https://zddc.varasys.io Comma-separated allowlist of origins permitted to make cross-origin requests. Empty value disables CORS entirely. Default lets ZDDC tools served from zddc.varasys.io (e.g. via the bootstrap pattern) call back into your deployed server.

ZDDC_TLS_CERT=none disables TLS entirely (plain HTTP). Both cert and key must be set together when using real certificates.

CORS

The default ZDDC_CORS_ORIGIN=https://zddc.varasys.io exists so the canonical ZDDC tool builds (hosted at zddc.varasys.io) can call back into your deployed zddc-server without extra configuration. If you self-host the tools on your own domain (e.g. tools.acme.com), set:

ZDDC_CORS_ORIGIN=https://tools.acme.com

Multiple origins are comma-separated. To disable CORS entirely (e.g. when all clients are same-origin), set ZDDC_CORS_ORIGIN= (empty value). The middleware echoes the matched origin back per-request and sets Access-Control-Allow-Credentials: true so the upstream-set X-Email header crosses the boundary.

TLS

Plain HTTP (no TLS)

Set ZDDC_TLS_CERT=none to run without TLS. Recommended when an upstream reverse proxy (nginx, Caddy, Traefik) terminates external TLS and talks to zddc-server over plain HTTP on a private network:

podman run --rm \
  -v /srv/archive:/data:z \
  -e ZDDC_ROOT=/data \
  -e ZDDC_TLS_CERT=none \
  -e ZDDC_ADDR=:8080 \
  -p 8080:8080 \
  zddc-server

When ZDDC_TLS_CERT / ZDDC_TLS_KEY are empty (or when using real certificates), zddc-server generates an ECDSA P-256 self-signed certificate in memory at startup. The certificate changes on every restart — this is intentional and acceptable when an upstream reverse proxy terminates external TLS and uses this server only for encrypted in-datacenter transport.

To use a real certificate (e.g. from Let's Encrypt or an internal CA):

podman run --rm \
  -v /etc/ssl/zddc:/certs:z,ro \
  -e ZDDC_TLS_CERT=/certs/server.crt \
  -e ZDDC_TLS_KEY=/certs/server.key \
  ...

Authentication

zddc-server does not perform authentication itself. It reads the user's email address from a request header (default: X-Email) that must be set by an upstream reverse proxy (nginx, Caddy, Traefik, Azure Application Gateway, etc.) after authenticating the user.

If the header is absent, the user is treated as anonymous (empty email). A directory with no .zddc rules is publicly accessible; a directory with an allowlist requires a matching email.

.zddc Access Control Files

Place a .zddc YAML file in any directory to control access. Rules cascade from parent directories — child rules are appended to (not replaced by) parent rules.

# Example .zddc file
acl:
  allow:
    - "*@mycompany.com"       # all users at mycompany.com
    - "contractor@partner.com" # specific external user
  deny:
    - "intern@mycompany.com"  # override: block this specific user

ACL evaluation order

Rules are evaluated bottom-up: starting at the requested directory and walking toward the root. The first explicit match (allow or deny) at any level wins.

  1. Check deny patterns at the current level — if email matches → 403 Forbidden
  2. Check allow patterns at the current level — if email matches → allow
  3. No match at this level → walk up to parent directory and repeat
  4. If no .zddc files were found anywhere in the chain → allow (public, no rules)
  5. If .zddc files exist but email matched nothing → 403 Forbidden (not on any list)

This model supports three user tiers in a single tree:

Level Rule Effect
Root allow: ["*@company.com"] All company users see everything
Project dir allow: ["team@company.com"] Restricts to the project team
Vendor subdir allow: ["vendor@ext.com"] Grants a third-party access to their folder only

A vendor navigating to their subdirectory is allowed by the deepest matching rule, even if a higher-level rule would deny them.

Glob patterns

* matches any sequence of characters within one side of the @ boundary:

Pattern Matches
*@mycompany.com Any user at mycompany.com
alice@* alice at any domain
* Any non-empty email
alice@example.com Exact match only

Directory visibility

Directories for which the user lacks access are omitted from JSON listings entirely — they are neither listed nor queryable. A direct request to a denied path returns 403.

Landing Page and Tool Install

The recommended install drops install.zip (downloaded from https://zddc.varasys.io/install.zip) into ZDDC_ROOT/:

ZDDC_ROOT/
  index.html          ← landing page (current stable)
  archive.html        ← archive browser
  transmittal.html
  classifier.html
  mdedit.html
  _template/          ← template directory of level-1 bootstrap stubs;
                        rename a copy to <project-name>/ for each project
  Project-001/
    archive.html      ← level-1 stub: fetches ../archive.html
    transmittal.html
    classifier.html
    mdedit.html
  Project-002/
  …

This is fully self-contained — no external dependencies. To make the deployment auto-track a published channel from zddc.varasys.io, drop track-alpha.zip / track-beta.zip / track-stable.zip (also at https://zddc.varasys.io/) over ZDDC_ROOT/: those replace the root <tool>.html files with level-2 bootstrap stubs that fetch the named channel from zddc.varasys.io on each page load. See bootstrap/README.md for the full install guide, the ?v=… URL parameter for per-request version selection, and the ZDDC_CORS_ORIGIN env var that lets zddc-server accept cross-origin calls from the level-2 source.

The landing page fetches GET / (with Accept: application/json) to retrieve the list of top-level project directories the requesting user has access to. It renders checkboxes for each project and opens archive.html?projects=Proj-A,Proj-B when the user clicks "Open Archive".

Presets (named project selections) are stored in the browser's localStorage — no server-side state required.

Shared URLs: the ?projects= parameter is preserved in the archive browser URL so users can email direct links to a pre-filtered view. If the recipient does not have access to a project listed in the URL, a warning banner is shown.

Access Logging

Every HTTP request is logged as a structured slog entry at INFO level:

Field Description
ts Request arrival timestamp (RFC3339)
email User email from the configured header, or anonymous
method HTTP method
path URL path
status HTTP response status code
bytes Response body bytes written
duration_ms Request duration in milliseconds

Log output goes to stderr. Use ZDDC_LOG_LEVEL=warn to suppress access logs if needed, or pipe stderr to a log aggregator.

Virtual Archive Index (.archive)

Any URL path segment named .archive (configurable via ZDDC_INDEX_PATH) is intercepted by the server and treated as a virtual document index.

The index is built at startup by scanning all transmittal folders under ZDDC_ROOT. It maps each (trackingNumber, revision, modifier) tuple to the file from the chronologically earliest transmittal folder that contains it.

URL patterns

URL Resolves to
GET /Project/.archive/TRK-001.html Latest base revision of TRK-001
GET /Project/.archive/TRK-001_A.html Base revision A of TRK-001
GET /Project/.archive/TRK-001_A+C1.html Modifier C1 of revision A of TRK-001
GET /Project/.archive/ JSON listing of all resolvable trackingNumber.html entries

All responses are 302 Found redirects to the actual file URL. ACL is enforced on both the .archive context directory and the resolved target file.

Why "earliest" transmittal?

Any file claiming to be TRK-001_A (IFC) should be identical across transmittals (same content, same SHA-256). If the same tracking number and revision appears in multiple transmittals, the first one received chronologically is treated as the authoritative copy. A later arrival with a different hash is an error condition (to be detected separately).

Index refresh

The index refreshes automatically via an fsnotify filesystem watcher. Changes are debounced by 2 seconds before the relevant transmittal folder is re-indexed.

Note for Azure Files: Azure SMB mounts do not support inotify/fsnotify reliably. The watcher will log a warning and the index will only be updated by restarting the server.

ZDDC Filename Convention

The server parses filenames following the ZDDC convention:

trackingNumber_revision (status) - title.extension
Part Format Example
trackingNumber No spaces or underscores 123456-EL-SPC-2623
revision ~?[A-Z0-9]+(\+[CBNQ][0-9]+)? A, ~B, C+C1
status One of the valid status codes IFC, REC, ---
title Free text Electrical Specification

Valid status codes: IFA IFB IFC IFD IFI IFP IFR IFU REC RSA RSB RSC RSD RSI ---

Transmittal folder format: YYYY-MM-DD_trackingNumber (STATUS) - title

Integration with Archive Browser

The Archive Browser (archive.html) can connect to zddc-server in HTTP mode. The server returns JSON directory listings in exactly the same format as Caddy's file-server --browse — no changes to archive/js/source.js are needed.

To use: install archive.html at ZDDC_ROOT/archive.html (or any subdirectory) — either the actual built tool from install.zip, or a level-1/level-2 bootstrap stub that fetches it. Then open it via the zddc-server URL; the app will auto-connect and scan the directory tree.

Container image

Each zddc-server-vX.Y.Z git tag publishes a runtime image to Codeberg's container registry via the Woodpecker CI pipeline at .woodpecker.yml:

codeberg.org/varasys/zddc-server:X.Y.Z
codeberg.org/varasys/zddc-server:latest

Pull and run:

podman run --rm -p 8443:8443 \
  -v /srv/archive:/srv:Z \
  -e ZDDC_TLS_CERT=none \
  -e ZDDC_ADDR=:8080 \
  -e ZDDC_INSECURE_DIRECT=1 \
  codeberg.org/varasys/zddc-server:latest

The image:

  • alpine-based, runs as non-root (UID 1000)
  • exposes 8443 by default
  • defaults ZDDC_ROOT=/srv (override or mount your archive there)
  • bundles the landing + archive tool HTML at /opt/zddc-server/web for self-contained demos (ZDDC_ROOT=/opt/zddc-server/web)
  • declares VOLUME /srv so the operator's data mount is explicit
  • ships a HEALTHCHECK for docker run; Kubernetes deployments override it

Env-var contract (for chart consumers)

Downstream Helm charts and Compose files should set these explicitly rather than relying on image defaults:

Variable Typical value (behind ingress + SSO) Purpose
ZDDC_ROOT /srv Path of the served archive (volume mount)
ZDDC_TLS_CERT none TLS terminated upstream
ZDDC_INSECURE_DIRECT 1 Acknowledge plain HTTP behind a trusted proxy
ZDDC_ADDR :8080 Match service / probe port
ZDDC_EMAIL_HEADER X-Auth-Request-Email Header your auth proxy sets
ZDDC_CORS_ORIGIN https://your-host Origins permitted to call back into the server

See "Environment Variables" above for the full list.

Building

Run as a container (build locally)

podman build --target server -t zddc-server .

Compile native binaries (no Go installation required)

Use the binaries build target to cross-compile for all platforms using podman as the build environment. Binaries are extracted directly to a local dist/ directory — no container runs on your host.

# From the zddc/ directory
mkdir -p dist
podman build --target binaries -o dist/ .

This produces:

File Platform
dist/zddc-server-linux-amd64 Linux (x86-64)
dist/zddc-server-darwin-amd64 macOS (Intel)
dist/zddc-server-darwin-arm64 macOS (Apple Silicon)
dist/zddc-server-windows-amd64.exe Windows (x86-64)

All binaries are statically linked (CGO disabled) — no runtime dependencies.

Run the binary directly:

# Linux / macOS
ZDDC_ROOT=/srv/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 ./dist/zddc-server-linux-amd64

# Windows (PowerShell)
$env:ZDDC_ROOT="C:\archive"; $env:ZDDC_TLS_CERT="none"; $env:ZDDC_ADDR=":8080"
.\dist\zddc-server-windows-amd64.exe

Docker users: Replace podman build with docker build. The --target and -o flags work identically in Docker BuildKit (enabled by default in Docker 23+).

Release Tagging

Follow the repository convention: zddc-server-vX.Y.Z

git tag zddc-server-v1.0.0
git push --tags

The zddc-server-v* tag triggers the .woodpecker.yml pipeline which builds and publishes the container image. See "Container image" above for the resulting image URLs.

The first time the pipeline runs you must configure two Woodpecker secrets in repo Settings → Woodpecker:

These never live in the repo; they are referenced from the pipeline via from_secret:.


Notes:

  • The container uses a multi-stage build
  • The .archive virtual path resolves ZDDC tracking numbers to their earliest-received revision
  • ACL is enforced via bottom-up .zddc file evaluation