Commit graph

158 commits

Author SHA1 Message Date
9765fa2f5e feat(apps): code-signed URL fetches; dev chart overlays prod data RO
Two interlocking pieces shipped together:

1. Strict Ed25519 signature verification on URL-fetched apps artifacts.
   Every URL the apps cascade resolves must publish a corresponding
   <url>.sig (raw 64-byte Ed25519 signature). The fetcher rejects on
   any failure (sig 404, transport error, wrong key, tampered body)
   and the resolver falls back to the embedded copy.

   The trusted public key is OPERATOR-CONFIGURED via --apps-pubkey /
   ZDDC_APPS_PUBKEY (PEM file path). No baked-in default — same posture
   as TLS certificates. Operators using zddc.varasys.io's canonical
   channels download pubkey.pem from there and configure the local
   path. Operators with their own signing infrastructure pass their
   own public key.

   Build pipeline (./build) gains sign_release_artifacts: walks
   dist/release-output/ after promote and produces an Ed25519 .sig
   alongside every real file. ZDDC_SIGNING_KEY=~/.config/zddc-signing/
   key.pem (mode 0600). Symlinks skip — the .sig at the symlink
   target is what counts.

   Test coverage: parse-PEM round-trip, malformed/wrong-type PEM
   rejection, valid-signature accept, tampered-body reject, wrong-key
   reject, malformed-signature reject, end-to-end fetch+sign+verify,
   fetch-rejects-tampered, fetch-rejects-missing-sig, fetch-rejects-
   wrong-key. Existing fetch tests updated to use signed-fixture
   helpers.

2. Dev Helm chart mounts production data READ-ONLY and layers an
   OverlayFS writable scratch on top. Prod data is the lowerdir;
   dev's writes (form submissions, archive index state, .zddc edits)
   land in upperdir; main container sees the merged read-write view
   at $ZDDC_ROOT. Setup runs in a privileged init container; main
   container runs unprivileged. Solves the dev-replica-on-shared-
   dataset problem at the filesystem layer with no zddc-server code
   change.

Docs: env-var tables in zddc/README.md and AGENTS.md gain a
ZDDC_APPS_PUBKEY row. The Federal-readiness gap analysis "Code-signed
apps: URL fetches" subsection is rewritten as "what's currently in
place" instead of "what would need to be added," with a forward
pointer to per-entry signed_by: (multi-key) and Sigstore as the
federally-acceptable evolution.

The website "Verify your downloads" section + the embedded pubkey
gone — but the website needs separate updates landing in zddc-website
to publish pubkey.pem and add the verify section. Pending in that
repo's commit.

Production binary unchanged at 13.1 MB. All 11 Go test packages green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:59:07 -05:00
8b33683a59 docs(server): apps composer on releases page; deeper federal-readiness gap analysis
Two doc/website improvements:

build:341 build_releases_index() — new "Build your apps: block" section
between the pinning narrative and the channels explainer. Per-app
dropdowns (one each for archive/transmittal/classifier/mdedit/landing),
a live-updating YAML textarea, and a Copy button. The dropdowns clone
their options from the existing #version-picker (channels at top,
pinned versions below) so we don't duplicate version data into JS —
the picker is the single source of truth for "what versions exist."
~80 lines of HTML+JS added; no SHA-256 anywhere (per user direction
that code signing is the future supply-chain answer, not hash pinning).

zddc/README.md § Federal-readiness gap analysis — promoted four items
that previously were one-line bullets to per-item subsections so a
future implementor doesn't have to redo the design conversation:

  - FIPS-validated cryptography (NIST SC-13): captures cgo + OpenSSL
    implications, the platform-matrix reality, and the parallel
    zddc-server-fips build target architecture (linux-amd64 only,
    RHEL/UBI base, validated OpenSSL on host).

  - Authenticated proxy↔server channel (NIST IA-3): mTLS vs JWT
    trade-offs spelled out. Recommended: JWT first; mTLS available
    for deployments that already operate a private CA.

  - Policy export for change control (NIST CM-3): zddc-server policy
    export subcommand emitting every directory's resolved ACL in
    JSON / Markdown / CSV. Reuses zddc.ScanZddcFiles +
    zddc.EffectivePolicy + zddc.MatchesPattern.

  - Code-signed apps: URL fetches (NIST SI-7): replaces SHA-256
    pinning (operator hash-tracking burden) with code signing
    (operator trusts a public key once). Three-part implementation
    (build pipeline signs, public key on website, verifier in
    apps/fetch.go).

The bullet list at the top of the gap analysis stays as a one-line
index pointing at the subsections.

Items #6 (ABAC roles) and #7 (logs: block in root .zddc) stay as
bullets — commercial-deployment features, not federal-track.

No code changes to the binary. No tests touched. ~280 lines added
across the two files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:32:58 -05:00
570604ca80 docs(mdedit): fix stale 'Select Directory' reference in README
The button was renamed from 'Select Directory' to 'Add Local Directory'
in 22c142e (chore(headers): standardize across all 7 tools). The
template, in-tool welcome hint, and in-tool help text all updated;
mdedit/README.md was missed.

Investigated the underlying bug report ('Select Directory button isn't
showing'): the button itself is fine — present in template.html with
btn-primary class, never hidden, only visually demoted to btn--subtle
once a directory is loaded. The user's report was new-label confusion
from the rename. Fixing the README to match the rendered label closes
the discrepancy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:07:37 -05:00
d3a9ea7ad9 feat(server): federal-mode reference Rego (parent-deny-is-absolute)
Ship a second parity-tested Rego policy that flips the cascade's
leaf-allow-overrides-parent-deny rule for NIST AC-6 conformance.

Standard cascade (existing access.rego, mirrors internal Go evaluator):
  Bottom-up walk; first explicit match wins; deny-first within a level.
  A leaf-level allow CAN override an ancestor's deny. This is the
  cascade's intentional delegation property — a project-owner who
  re-allows a previously-denied collaborator works as expected.

Federal mode (new access_federal.rego):
  Any deny anywhere along the chain is absolute. An allow only matters
  if no level (any depth) has denied the same email. Required by
  NIST AC-6 default expectations: a central admin's deny at the root
  must be unbypassable by a tenant who controls a subtree's .zddc.

Operators run real OPA with this Rego and point ZDDC_OPA_URL at it;
the internal Go evaluator stays on the commercial cascade. The
toggle is "which policy does your OPA evaluate," not a knob inside
zddc-server.

Surfaced via --print-rego flag:

  zddc-server --print-rego               # standard (default)
  zddc-server --print-rego=standard      # same
  zddc-server --print-rego=federal       # AC-6 strict variant

Parity test (federal_parity_test.go) compiles both Regos and asserts:
  * They AGREE on every cascade scenario where no ancestor-deny
    intersects a leaf-allow (most cases).
  * They DISAGREE — by design — on the three scenarios where the
    AC-6 rule differs:
      - "leaf allows what parent denied" → standard allows, federal denies
      - "deep leaf re-allows after middle deny" → same
      - "glob deny at root + specific allow at leaf" → same

Cross-checks the divergence flag explicitly so any future change that
accidentally collapses the two policies fails the test.

Closes the AC-6 row of the federal-readiness gap analysis (now marked
"partially complete" in zddc/README.md — the full bullet would be a
built-in --policy-mode=federal toggle that also flips the in-process
Go evaluator).

Production binary unchanged at 13.1 MB (Rego files embedded as bytes;
OPA library remains test-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:05:44 -05:00
2607ca9b8a feat(server): /.profile/effective-policy cascade tracer (admin-only)
Eliminates the manual cascade-trace ritual when debugging "why can't
alice see /Project-X" reports. New endpoint returns the resolved
policy chain plus the active decider's verdict in JSON:

  GET /.profile/effective-policy?path=/Project-X/sub/&email=alice@…

Response shape:

  {
    "path": "/Project-X/sub/",
    "email": "alice@…",
    "decision": true,
    "decider_kind": "*policy.InternalDecider",
    "chain": {
      "has_any_file": true,
      "levels": [
        {"index": 0, "zddc_path": "/.zddc", "exists": true,
         "acl": {...}, "admins": [...],
         "matches_email": false, "decision_at_level": "no_match"},
        {"index": 1, "zddc_path": "/Project-X/.zddc", "exists": true,
         "acl": {...}, "matches_email": true, "decision_at_level": "allow"}
      ]
    }
  }

Per-level email matching reuses the same MatchesPattern code the live
evaluator uses, so the trace can never disagree with the actual
verdict — and when ZDDC_OPA_URL points at an external OPA, the
decision goes through that OPA, making the endpoint a useful smoke
test for OPA wiring too.

Admin-only via the existing /.profile gate (404 to non-admins).
Required params; 400 if either is missing or path doesn't escape ROOT.

Test coverage:
  * TestServeProfileGateMatrix: anonymous → 404, non-admin → 404,
    admin without params → 400 (gate cleared, validator rejected)
  * TestServeProfileEffectivePolicy: full payload-shape assertion
    against a worked-example fixture (closed project where alice is
    allow-listed but bob is not)

Also fixes pre-existing doc drift: README's "Admin Debug Page"
section referenced /.admin/whoami|config|logs but the actual code
mounts /.profile/* (the rename predates this PR; the doc was stale).

Closes the "/.admin/effective-policy debug endpoint" item from the
federal-readiness future-work list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:01:24 -05:00
b20e98b6aa fix(apps): cache key now includes scheme + full host:port (no collisions)
The previous keyForURL stripped default ports (:443 for https, :80
for http) and omitted the scheme, so:

  http://example.com/x.html   ──┐
  https://example.com/x.html  ──┴──→ same cache entry (collision)

  https://example.com/x.html      ──┐
  https://example.com:443/x.html  ──┴──→ same cache entry

This was a defensible HTTP convention but a real correctness issue
on reverse-proxy stacks where http and https legitimately serve
different bytes for the same path, or where two upstreams share a
host but answer on different default ports.

New layout: <scheme>/<host>[:<port>]/<path>. Full origin tuple in
the key, no port stripping, scheme segregation. Examples:

  https/zddc.varasys.io/releases/archive_stable.html
  https/example.com:8443/x.html
  http/example.com/y.html      (distinct from https/example.com/y.html)

Operators retain the "ls _app/ to inspect what's cached" affordance
they relied on; they just see one extra directory layer (scheme
first, then host).

Tests:
  * Updated TestKeyForURL to assert the new layout for every
    previously-covered case
  * New TestKeyForURL_NoCollisions explicitly asserts that the
    dimensions previously collapsed (default-port↔bare, http↔https,
    different non-default ports) now produce distinct keys

Doc references to the cache layout under <ZDDC_ROOT>/_app/ updated
in zddc/README.md (3 mentions).

NOTE: existing _app/ caches under the old layout will be ignored
on first request after upgrade — entries will be re-fetched and
written to the new path. Operators can `rm -rf <ZDDC_ROOT>/_app`
during the upgrade window if they prefer not to have orphans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:57:28 -05:00
460d5fdada feat(server): TLS hardening per NIST SP 800-52 Rev. 2 + HSTS
The TLS configuration was using Go stdlib defaults — secure for typical
commercial use, but federal evaluators need an explicit cipher
allowlist they can map to a FIPS-validated implementation. Pin the
cipher and curve lists to NIST SP 800-52 Rev. 2 § 3.3 conformant
values:

  Ciphers (TLS 1.2):
    TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
    TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
    TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
    TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
    TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
    TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305

  Curves: X25519, P-256, P-384

  MinVersion: TLS 1.2 (already set; 1.3 used when negotiated)

TLS 1.3 cipher selection is not operator-controllable in Go stdlib
(the runtime picks from a fixed AEAD-only set); all of those
already meet the federal bar so no change needed there.

Also adds HSTSMiddleware emitting `Strict-Transport-Security:
max-age=31536000; includeSubDomains` when zddc-server is itself
terminating TLS (ZDDC_TLS_CERT != none). Behind an upstream proxy
terminating TLS the proxy is responsible for HSTS, so the middleware
only wraps the chain when useTLS=true.

Test coverage:
  * TLSConfig(none) returns nil + useTLS=false
  * TLSConfig(selfsigned) sets the exact NIST allowlist
  * Negative test asserting weak ciphers (CBC, RC4, 3DES, RSA-key-
    exchange) are NOT in the list — guardrail against regressions

Federal-readiness gap analysis updated: this control is now partially
complete. OCSP stapling and CT-log inclusion remain on the list for
full DoD STIG conformance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:55:52 -05:00
ca0364c197 docs: add SECURITY.md (vulnerability disclosure policy)
OpenSSF-template-shaped policy covering supported versions, reporting
channels (private email primary; GitHub Security Advisories on the
mirror as a structured-coordination secondary), response timeline, the
embargo+disclosure flow, CVE assignment process, and in-scope vs
out-of-scope examples.

Closes the NIST SI-5 (vulnerability disclosure) gap from the
federal-readiness gap analysis. Useful for every customer, federal
or not — formalizes the "where do I report this" question that
otherwise goes unanswered.

Out-of-scope examples explicitly enumerate the documented behaviors
that reporters sometimes mistake for vulnerabilities (email-header
forgery via direct bind-address connection; anonymous info disclosure
on /; apps: URL-fetch trust). Saves both reporter and maintainer time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:53:13 -05:00
5c33c8a821 docs: ACL/security overhaul (cascade rules, OPA, caching)
Three docs aligned with the preceding three feature commits.

zddc/README.md
--------------
Major overhaul of the access-control narrative. The previous "three-
tier" example table was misleading: it claimed a project-level
allow-list "restricts" access under a parent wildcard, when actually
the cascade is additive (a non-team employee falls up to root and
matches *@company.com). Operators reading the old docs would build
deployments that looked locked-down but leaked across the company.

New sections under "Access control: the .zddc cascade":
  * Step 1: starter .zddc — leads with the public-by-default warning
    and the --insecure escape hatch
  * How a request is evaluated — bottom-up walk with code citations
  * Glob patterns — @-boundary rule
  * When the cascade helps and when it fights you — the asymmetry
    between adding strangers (easy) and excluding insiders (hard)
  * Pick your layout — decision matrix for common shapes
  * Worked example: paired open/closed projects + third-party archive
    — full layout with trace table for two representative users
  * Patterns that look secure but aren't — anti-patterns including
    same-level allow+deny shadow, leaf-allow-doesn't-restrict,
    apps:-as-UI-mount
  * Trust model and invariants — auth boundary, subtree authority,
    root-only escalation gate
  * Trust boundary — network isolation requirement, anonymous
    information disclosure on /, audit-log integrity
  * Debugging permissions — manual cascade trace
  * Directory visibility / Reserved hidden segments
  * How to verify in 5 minutes — recipe with negative anti-pattern test
  * Federal-readiness gap analysis — bulleted with NIST control refs
  * External policy decider — OPA wire format, deployment shapes,
    failure modes
  * OPA decision cache — TTL semantics, knobs
  * Reference Rego policy — --print-rego, parity test rationale
  * Caching and ETags — content-hash story, why not server-side
  * Future work

Plus env-var table updates for ZDDC_INSECURE, ZDDC_OPA_URL,
ZDDC_OPA_FAIL_OPEN, ZDDC_OPA_CACHE_TTL; CORS narrative reflects
default-empty.

ARCHITECTURE.md
---------------
New "Server security model" section between Form Renderer and CSS:
cooperating layers (auth / policy decider / cascade / tool-rooted
view / reserved prefixes / audit log), commercial-vs-federal trust
model side-by-side, why the tool-rooted view matters for third-party
containment.

AGENTS.md
---------
Two new env-var rows (ZDDC_OPA_URL, ZDDC_OPA_CACHE_TTL); ACL line
sharpened with cascade rules + cross-reference; ZDDC_CORS_ORIGIN
description updated for default-empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:46:57 -05:00
a01315fd00 feat(server): reference Rego, parity test, decision cache, listing ETags
Phase 2 enhancements to the policy decider, plus listing-level ETags
that benefit every deployment regardless of decider mode.

Reference Rego policy
---------------------
internal/policy/rego/access.rego mirrors InternalDecider's semantics
exactly — bottom-up walk, deny-first within a level, default-deny when
HasAnyFile=true, glob matching with @-boundary semantics (special-cased
bare "*" because OPA's glob.match treats empty delimiters
inconsistently for that pattern).

Embedded into the binary via go:embed; --print-rego dumps it to stdout
so federal customers standing up an external OPA can use it as a
parity-tested baseline:

    zddc-server --print-rego > /etc/opa/policies/zddc-access.rego

Parity test runner
------------------
parity_test.go imports the OPA Go module as a TEST-ONLY dependency
(github.com/open-policy-agent/opa@v0.70.0). Every fixture from the
internal Go evaluator's test set runs through both implementations;
any divergence fails CI. The test-only import means production
binaries (built by `go build ./cmd/zddc-server`) stay OPA-free —
release-flag binary size unchanged at ~13 MB.

The parity test caught a real bug on first run: bare "*" patterns
didn't match through OPA's glob.match with empty delimiters. Fixed
in access.rego with a special-case rule. This is exactly the kind of
subtle drift the parity guard exists to catch.

External-mode decision cache
----------------------------
HTTPDecider is now wrapped in a cachingDecider with a default 1s TTL.
Bursty patterns like .archive listings (one OPA round-trip per entry
before, one per (email, decision-input) tuple per TTL window after)
amortize cleanly. Verified: 20 identical /D/ requests produce 1 OPA
hit with cache, 40 hits without (each listing makes 2 ACL queries).

ZDDC_OPA_CACHE_TTL knob (default 1s) lets operators tune. 0 disables.
1s matches the fsnotify watcher debounce window — staleness is
bounded the same way other policy-edit propagation already is.
Internal mode unchanged; the in-process Go evaluator is already
cheaper than a cache lookup would be.

Listing ETags
-------------
GET / (project list) and GET /<dir>/ (directory listing JSON) now
carry content-hash ETag + Cache-Control: private, max-age=0,
must-revalidate. SHA-256 of the rendered JSON, truncated to 16 hex
chars (64 bits — collision risk on a listing of any realistic size
is vanishingly small).

Server-side caching deliberately not added: it would require
mtime-based invalidation, and Azure Files SMB mounts (a common
deployment substrate) don't support fsnotify reliably. The
content-hash ETag delivers the bandwidth savings (304 on identical
fetches) without depending on watcher correctness — the hash is the
actual response, so it can't lie about staleness regardless of
underlying watcher behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:46:24 -05:00
e911806eda feat(server): pluggable OPA-compatible policy decider
Add an internal access-decision boundary that all handlers go through
instead of calling zddc.AllowedWithChain directly. Two implementations
ship:

  * InternalDecider — wraps the existing zddc.AllowedWithChain. The
    default. No new dependencies, identical semantics to the legacy
    code path. ZDDC_OPA_URL=internal (or unset).

  * HTTPDecider — POSTs the canonical OPA wire format
    (POST /v1/data/zddc/access/allow with {"input": {...}}, response
    {"result": true|false}) over HTTP, HTTPS, or a Unix-domain socket.
    For federal customers running their own audited Rego policies
    alongside zddc-server. ZDDC_OPA_URL=http(s)://… or unix:///….

External-mode failure semantics: unreachable / non-2xx / malformed
response → fail closed (deny) by default with a WARN log. Operators
who prefer availability over correctness flip with ZDDC_OPA_FAIL_OPEN=1.

The decider is constructed once at startup, plumbed through ACLMiddleware
into the request context. Handlers retrieve it via DeciderFromContext;
non-request callers (fs.ListDirectory, EnumerateProjects, enumerateAccess)
take it as an explicit parameter.

zddc.ZddcFile and zddc.ACLRules gain JSON tags so external Rego authors
get idiomatic input shape (acl.allow, admins, …) instead of Go field
names (ACL.Allow, Admins, …).

Test coverage:
  * InternalDecider parity tests against zddc.AllowedWithChain (every
    documented cascade scenario: empty chain, leaf-allow-wins, leaf-
    deny-beats-parent, leaf-allows-what-parent-denies, deepest-match-
    wins, etc.)
  * HTTPDecider happy-path test (canonical wire format)
  * Fail-closed / fail-open / malformed-response tests

Production binary size unchanged (no new deps; HTTP transport is
stdlib net/http). 11 ACL call sites migrated. End-to-end verified
against the worked-example layout in zddc/README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:45:07 -05:00
6b973906c3 feat(server): refuse to start without root .zddc; default CORS to empty
Two safe-by-default flips, both opt-out via explicit acknowledgement.

1. --insecure / ZDDC_INSECURE=1: zddc-server now refuses to start when
   no <ZDDC_ROOT>/.zddc exists. With no .zddc anywhere in the chain,
   AllowedWithChain falls through to "HasAnyFile=false → allow" and
   the tree is publicly accessible to anonymous callers — almost never
   what an operator wants on a fresh deployment, and previously a
   silent footgun. The flag is the escape hatch for deliberately-
   public archives (no .zddc anywhere by design).

2. ZDDC_CORS_ORIGIN now defaults to empty (CORS disabled) instead of
   the canonical "https://zddc.varasys.io". The embedded-tools install
   path serves tools and data same-origin, so the default never needed
   to permit cross-origin XHRs from a third-party host. Every deployment
   was implicitly trusting zddc.varasys.io to make authenticated XHRs
   on behalf of every logged-in user; if that origin were ever
   compromised, the blast radius extended to every customer server.
   Operators who deliberately use the CDN-bootstrap pattern or self-
   hosted tools at a different host now set the value explicitly.

Helm chart values updated accordingly: prod default is empty; dev
keeps localhost:8000 for tool-iteration workflows. Existing deployments
that depended on the old defaults will need to either set the value
explicitly or pass --insecure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:40:34 -05:00
9d5430db81 fix(ci): chart-bump script writes full 40-char SHA to appVersion
Forgejo's uploadpack.allowAnySHA1InWant only matches FULL SHAs —
the 7-char short SHA from build-label / versions.txt produces
"couldn't find remote ref ae75855" on `git fetch --depth=1 origin
<short-sha>` (and `git clone --branch <short-sha>` fails the same
way). The chart's downstream Dockerfile uses fetch-by-ref to handle
SHAs as well as named refs, but only full SHAs go through.

Resolve the short SHA to its full form via `git rev-parse` in
notify-chart-bump.sh before writing the chart's appVersion. The
runner has the full git history (actions/checkout@v4 fetch-depth: 0),
so rev-parse works locally; the chart's appVersion becomes the
canonical 40-char SHA, and the BMCD pipeline-dev / pipeline-prod
fetches succeed cleanly.

Manually re-bumped chart develop after this commit:
  appVersion: 0.0.16-beta-ae75855 → 0.0.16-beta-ae758550a855f6a9507df08075475cb87cb67086
2026-05-04 08:01:34 -05:00
73a05e4e46 chore(embedded): cut v0.0.16-beta — refresh SHA off the dangling 8df0def
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
The recent history rewrite (squash of 4 thrash CI commits) made the
chart's previous appVersion (0.0.16-beta-8df0def) reference a now-
dangling commit. The dev pipeline failed clone "remote ref not
found" until we re-bumped to a SHA in the new history.

Re-cut beta with the new HEAD parent (ae75855) so notify-chart-dev
rewrites the chart's appVersion to a SHA the BMCD dev pipeline can
actually fetch. Combined with the Dockerfile clone-via-fetch fix in
tnd-zddc-chart 86c5758 (handles bare SHAs), the dev pipeline should
build cleanly.
2026-05-04 07:57:35 -05:00
ae758550a8 docs: surface recent server features in README + AGENTS
zddc/README.md and AGENTS.md hadn't caught up with the loading-
efficiency + ops-hygiene work. Add coverage for:

- ETag + max-age=0 on embedded tool HTMLs (304 on revalidation)
- gzip compression middleware (75% size reduction on bodies > 1 KB)
- public landing page semantics (root bypasses dir-level ACL;
  per-project filtering still hides hidden projects)
- file-based audit log (default-on, auto-mkdir, hostname-tagged
  filename + record field, lumberjack-rotated)
- HTTP timeouts (slowloris-resistant)

Adds ZDDC_ACCESS_LOG row to both env-var tables.
2026-05-04 07:49:17 -05:00
360601e262 fix(ci): chart MINOR-bump on stable cut (resets PATCH to 0)
Chart version was monotonically incrementing PATCH on every chart-bump
— after ~50 betas + a few stable cuts, version was already at 0.2.12.
Triple digits would land within a year of active dev.

Switch the stable-cut branch of notify-chart-bump.sh to bump MINOR
and reset PATCH to 0:
  stable: 0.2.X → 0.3.0
  beta:   0.3.0 → 0.3.1, 0.3.1 → 0.3.2, ...
  next stable: 0.3.X → 0.4.0

Patches stay bounded (≈ betas-per-stable, not all-time). MINOR keeps
incrementing so JFrog chart-repo uploads stay accepted (it rejects
duplicate chart-version numbers — a literal "reset to 0.2.0" cycle
would break uploads on the second stable cut).

Chart version is purely JFrog packaging metadata; the zddc-server
version users actually care about lives in appVersion.
2026-05-04 07:49:17 -05:00
c9f6d08be1 chore: untrack mdedit/dist/mdedit.html — every other dist/ is gitignored
mdedit was the only tool whose dist/<tool>.html was force-tracked
(via `git add -f` in the build's stable-cut path). Inconsistent with
every other tool in the repo, where dist/ is fully gitignored. The
build regenerates mdedit/dist/mdedit.html the same way it regenerates
the others, so there's no reason to track it.

Drop the `git add -f` line in build:735 and `git rm --cached` the
file. The on-disk artifact stays put for the dev iteration loop;
only the index entry goes away.
2026-05-04 07:49:17 -05:00
df1c32ff54 feat(server): HTTP timeouts + audit log default-on with hostname tagging
Two related operational improvements:

1. HTTP timeouts on http.Server (ReadHeaderTimeout 10s, ReadTimeout +
   WriteTimeout 60s, IdleTimeout 120s). Caps slow-client connection
   hold time; closes the slowloris vector. Listing + tool-HTML
   responses complete in milliseconds even with gzip, so 60s is
   generous for legit traffic.

2. --access-log defaults to <ZDDC_ROOT>/.zddc.d/logs/access-<host>.log
   instead of stderr-only. The server auto-creates the parent tree
   (mode 0750), so a fresh deployment gets an audit trail without
   operator setup. Every JSON record carries a `host` field (from
   os.Hostname) — multi-replica deployments share the .zddc.d/logs/
   directory but write to per-host filenames, and downstream
   aggregators can disambiguate via the host field.

   Opt-out: --access-log= (explicit empty). Distinguishing "unset"
   from "set to empty" follows the same pattern config.go already
   uses for --cors-origin.

Live verification:
  $ zddc-server -root /tmp/r -addr 127.0.0.1:8765 -tls-cert none -insecure-direct
  $ curl http://127.0.0.1:8765/
  $ ls /tmp/r/.zddc.d/logs/
  access-bizon.log
  $ tail -1 /tmp/r/.zddc.d/logs/access-bizon.log
  {"time":...,"level":"INFO","msg":"access","host":"bizon",...,"email":"anonymous","method":"GET","path":"/","status":200,...}

  $ zddc-server -root /tmp/r ... -access-log=  # opt-out
  $ ls /tmp/r/.zddc.d/  # empty: no logs/ created
2026-05-04 07:49:17 -05:00
b8192c5d7a fix(ci): chart-bump uses the SHA baked into the embed (not git HEAD)
./build beta runs locally at HEAD=N, generates embed/*.html with
build-label SHA=N, then the operator commits the generated bytes
as commit N+1. After push, git HEAD on the runner = N+1 but the
served website's BUILD_LABEL still encodes N (it's baked into the
HTML at build time, before the commit).

Previously the bump script computed BETA_VERSION using
`git rev-parse --short HEAD` = N+1, so the chart's appVersion
(and the dev image's tag, and the kubelet's pull) said N+1 while
the served label said N. Two SHAs for the same bytes — confusing
when triaging "is this image actually the latest?".

Read the SHA from zddc/internal/apps/embedded/versions.txt instead
(third pipe-delimited field of any line). That's the single source
of truth for what bytes were baked, and it lines up with what users
see in every tool's header.

Manually re-bumped chart develop after committing this script:
  appVersion: 0.0.16-beta-9a3e4d8 → 0.0.16-beta-8df0def
2026-05-04 07:49:17 -05:00
8925345129 chore(embedded): cut v0.0.16-beta with loading-efficiency wins
Bake into the dev binary:
  - ETag + max-age=0 on embedded HTML (304s on repeat loads)
  - gzip compression middleware (~75% wire-size reduction)
  - vendored jszip + docx-preview in archive/transmittal/classifier
  - tee'd file-based access log via --access-log
2026-05-04 07:49:17 -05:00
411f49169b feat(server): tee access log to a rotated file for on-disk audit trail
Add --access-log <path> (env ZDDC_ACCESS_LOG). When set, every access-
log record is written as a JSON line to the configured file in
addition to the existing slog.Default() stderr output. Empty (default)
keeps the prior behavior — stderr only.

Rotation via gopkg.in/natefinch/lumberjack.v2:
  100 MB per file, 10 backups, 90-day max age, gzip rotated files.

Operator usage (e.g. behind a Caddy/quadlet stack):
  zddc-server --access-log /srv/.zddc.d/logs/access.log ...

Architecture:
  AccessLogMiddleware now takes an optional *slog.Logger. main.go wires
  it via setupAccessAuditLog() which builds a slog.JSONHandler over a
  lumberjack rotator. Stderr emission stays via slog.Default(); the
  audit logger gets the same fields in line-delimited JSON, the format
  every standard log shipper (Vector, Loki, fluentbit, journalbeat)
  parses natively.

Tests cover the audit logger receiving the same email/path/status
fields as the stderr stream.
2026-05-04 07:49:17 -05:00
9481122570 perf(tools): vendor jszip + docx-preview for archive/transmittal/classifier
Same pattern as the browse fix. archive, transmittal, classifier
previously CDN-loaded jszip + docx-preview on first preview of a
.zip / .docx file via shared/preview-lib.js's loadLibrary helper.
That meant each first-preview blocked on a CDN round-trip + parse,
and broke entirely under restrictive networks or CSPs.

Vendor both libs under shared/vendor/ and concat them at the top of
each tool's build, ahead of init.js. window.JSZip + window.docx are
now defined immediately on page load. Drop the redundant loadLibrary
calls (and classifier's stray <script src="cdn..."> tag in the
template, plus archive's bespoke loadJSZip helper in export.js).

xlsx (SheetJS) intentionally stays CDN-loaded — at ~900 KB it's too
large to inline, and only fires on .xlsx preview which is a rarer
path.

Bundle size impact (uncompressed):
  archive:     304 KB → 476 KB  (+172 KB)
  transmittal: 449 KB → 621 KB  (+172 KB)
  classifier:  252 KB → 424 KB  (+172 KB)

With the gzip middleware (~75% reduction on HTML) and ETag-cached
revalidation now in place, the wire-size delta is ~40 KB per tool
on the first load and 0 on every subsequent load until redeploy.
2026-05-04 07:49:17 -05:00
50dd8f9bda perf(server): gzip compression middleware on the entire mux
Add github.com/klauspost/compress/gzhttp wrapper around the request
handler. With MinSize(1024), responses ≥ 1 KB get gzip-encoded when
the client advertises Accept-Encoding: gzip; smaller bodies + 304
Not Modified pass through unchanged.

The wrapper auto-appends Vary: Accept-Encoding (compatible with the
existing Vary: Accept on directory.go's content-negotiated path).

Live-tested against zddc-server -root /tmp/empty:
  GET / w/ Accept-Encoding: gzip → 20.9 KB compressed (was 80.9 KB
                                   uncompressed). 74% reduction.
  Decompresses cleanly back to the original bytes.

Helps every code path that bypasses Caddy: devshell pods, local dev
binaries, tests, anywhere zddc-server is hit directly. Production
behind Caddy already had compression at the proxy layer; this just
makes the Go server self-sufficient.

Tests in cmd/zddc-server/main_test.go cover:
- large body + Accept-Encoding → compressed + Vary header
- small body → not compressed (under MinSize)
- no Accept-Encoding header → plain bytes
2026-05-04 07:49:17 -05:00
ed7a7fc9c0 perf(server): ETag + max-age=0 on embedded HTML responses
The apps subsystem previously sent Cache-Control: public, max-age=300|3600,
must-revalidate but no ETag. With must-revalidate and no validator, the
browser cannot return 304 — it has to refetch the full body once max-age
expires. For mdedit that's 920 KB on every reload after an hour.

Add a content-addressed ETag (sha256 hex prefix, 32 chars) to:
- apps/handler.go's serveBody + serveEmbedded (both paths now emit ETag
  + handle If-None-Match short-circuit to 304)
- handler/directory.go's embedded:browse fallback (mirror behavior so
  the bare-directory landing serves the same way)

Drop max-age to 0 with must-revalidate: every page load revalidates,
but a matching ETag returns 304 with empty body. Steady-state cost of
a reload drops from N KB to a few hundred bytes. When the binary is
redeployed, the ETag changes (content hash) and the next request
returns 200 with the new bytes.

Tests in apps/handler_test.go cover both paths:
- TestServer_Embedded_ConditionalGET: full GET, matching INM, stale INM
- TestEmbeddedETag_Stable: same bytes → same ETag, different → different

Live smoke (curl against zddc-server -root /tmp/empty):
  GET /            → 200, ETag set, body = 80919 bytes (landing.html)
  GET / + INM:tag  → 304 Not Modified, empty body
2026-05-04 07:49:17 -05:00
cc4ae3f0c4 chore(embedded): cut v0.0.16-beta with public-landing fix
Bake the public-landing-page server change into the dev binary.
2026-05-04 07:49:17 -05:00
20897fef6b feat(server): public landing page (root bypasses dir-level ACL)
GET / and GET /index.html previously enforced the root .zddc's
top-level acl: gate before serving the landing page. On a deployment
where only specific emails are allowed at root, anonymous (and
unauthorized) callers got 403 — they couldn't even see the project
picker that would tell them which projects were available to them.

Make the landing page public:
  - cmd/zddc-server: drop the AllowedWithChain gate from the
    apps.Serve("landing") branch; drop it from the IsDir branch when
    urlPath == "/".
  - handler/directory.go: matching bypass for ServeDirectory at the
    root path (covers Accept: application/json and the case where a
    real /index.html exists on disk).

Per-project ACL is preserved end-to-end:
  - fs.ListDirectory continues to filter sub-entries per email, so
    anonymous callers see only projects whose .zddc allows them.
  - Subdirectory requests still hit the ACL gate.

Regression test in handler/directory_test.go covers all four cases
(anonymous public, anonymous filters out private, admin sees both,
anonymous still 403 on private subdir). Full go test ./... passes.
2026-05-04 07:49:17 -05:00
d1ff060d3d chore(embedded): cut v0.0.16-beta
Bake the standardized headers + archive bugfix + browse refactor
into the dev binary. Triggers notify-chart-dev → bumps tnd-zddc-chart
develop with appVersion=0.0.16-beta-<sha>.
2026-05-04 07:49:17 -05:00
c603eb6cdb fix(archive): point getElementById at the real root id
drag-drop.js and the unsupported-browser handler in app.js both
referenced getElementById('app'), but the template's root has
id="appContainer". The mismatch was masked in production because
sourceMode='http' skips dragDrop.init() — only file:// (sourceMode=
'local') tripped over it, throwing "Cannot read properties of null
(reading 'addEventListener')" at app load.

Surfaced while header-standardizing the other tools; fixed by
pointing both callers at #appContainer.
2026-05-04 07:49:17 -05:00
22c142e45a chore(headers): standardize across all 7 tools
Bring every tool's header in line with archive's pattern:

  [logo] [title] [version] [Add Local Directory] [⟳] ............... [◐] [?]
  ------------- header-left ---------------       ----- header-right -

Changes per tool:

* browse: rename "Select Directory" → "Add Local Directory"; add the
  red-non-stable wrap to the build label (was missing); add a help
  panel + bundle shared/help.js.

* classifier: rename selectDirectoryBtn → addDirectoryBtn,
  refreshBtn → refreshHeaderBtn for consistency. Update all JS
  callers and welcome-screen copy to the new label.

* mdedit: same id rename. Move the previously-in-pane refresh
  button into the header. Stop renaming the dir button to
  "Directory: <name>" once a folder is loaded — instead use the
  shared btn--subtle variant to de-emphasize while keeping the
  standard label.

* transmittal: convert non-standard <div class="app-header"> with
  spacer/icons containers to <header class="app-header"> with the
  canonical header-left/header-right pair. Move the publish split-
  button into header-left (Transmittal-specific primary action).
  Remove dead .app-header__spacer/__icons/header-icon-btn CSS now
  that nothing references those classes.

* landing, form: add help-btn + help-panel + bundle shared/help.js.
  Each panel is tool-specific (project picker docs for landing,
  schema-driven form docs for form).

Cross-cutting:

* shared/base.css: promote .btn--subtle from browse/css/tree.css
  so any tool with an online mode can de-emphasize Add Local
  Directory consistently.

Verified all 7 tools in headless Chromium: header structure correct,
build label red on non-stable cuts, help panel opens + closes via
button + Esc.
2026-05-04 07:49:17 -05:00
a7faeed8fb refactor(ci): extract chart-bump logic to .forgejo/scripts/
Both notify-chart-dev.yml and the notify-chart-prod job in
deploy-release.yml were carrying ~80 lines of inline shell each,
duplicating the clone-bump-push flow. Extract to a single script:

  .forgejo/scripts/notify-chart-bump.sh <beta|stable> [VERSION]

Three benefits:

1. **Locally testable**. The script is invocable directly:
     CHART_FORGEJO_TOKEN=$FORGEJO_TOKEN \
       .forgejo/scripts/notify-chart-bump.sh beta
   No more "push to main and watch what the runner does" debug loop.

2. **Manual escape hatch**. When CI is broken, the same script is
   how we recover. The 0.0.16-beta-1ddd331 chart bump preceding
   this commit was performed via this very script.

3. **Runner-quirk-immune**. The previous three commits chased a
   Forgejo runner v12.9.0 phantom-SIGPIPE bug that would only
   surface under the runner's `bash -e -o pipefail {0}` wrapper.
   A real script with its own `#!/usr/bin/env bash` and explicit
   error handling sidesteps the wrapper entirely.

The workflow YAMLs shrink to checkout + run-script. No GITHUB_OUTPUT
plumbing, no inline if/else gates, no shell flag overrides. The
behavior is identical to the prior inline versions.
2026-05-04 07:49:16 -05:00
b14d8a3e38 chore(embedded): cut v0.0.16-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 4s
Bake the latest dev cut of all six tools into zddc/internal/apps/embedded/
so the dev image (built from main) ships the new browse filter UI +
vendored JSZip. Triggers notify-chart-dev which bumps the chart's
develop branch with appVersion=v0.0.16-beta-<sha>.
2026-05-03 21:39:28 -05:00
582db6d86d feat(browse): vendored JSZip, SVG home icon, auto-filter rows
- Vendor JSZip locally (shared/vendor/jszip.min.js) and bundle into
  the browse build instead of CDN-loading. Eliminates the failure
  mode where ZIP rows can't expand because the CDN script doesn't
  load (CSP, network, etc.). Tool now works fully offline.
- Replace the toolbar filter input + ext multi-select with two
  spreadsheet-style auto-filter rows in <thead>:
    - 📄 row: file-name filter + extension filter
    - 📁 row: folder-name filter
  Each input uses shared/zddc-filter syntax (substring/!negate/
  ^startsWith/$endsWith/regex/| or/space and).
- New visibility model with ancestor-of-match awareness:
    - file matches keep their ancestor folders visible (path-to-hit)
    - folder match keeps its descendants visible
    - filters compose (file ∧ folder ∧ ext) so combinations narrow
  Computed model-side; render walks only visible nodes.
- Replace 🏠 emoji breadcrumb-root with an inline outline-stroke SVG
  that tints with currentColor.
2026-05-03 21:35:15 -05:00
6e80e2bf12 release: v0.0.15 lockstep
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 4s
Build + deploy releases / build-and-deploy (push) Successful in 8s
Build + deploy releases / notify-chart-prod (push) Successful in 1s
2026-05-03 20:43:41 -05:00
a6c3c9df5e chore(headers): standardize titles + refresh icon across tools
Cross-tool header inconsistencies cleaned up after the audit
prompted by the browse Phase 2 work:

  - landing/template.html: title was 'ZDDC Archive' (a holdover from
    when landing WAS the archive). The page is now the project
    picker — title shortened to plain 'ZDDC'. Browser tab title
    follows: 'ZDDC Archive — Projects' → 'ZDDC — Projects'. Title +
    build label wrapped in title-group div for layout consistency
    with archive/classifier/mdedit/browse.
  - form/template.html: title was bare; same title-group wrapping.
    The id='form-title' stays — its content is overwritten at
    runtime by form.js based on the form schema's name.
  - classifier/template.html: refresh button text 'Refresh' →
    '⟳' icon to match archive + browse. Same title attribute, just
    smaller visual weight.

Untouched (intentionally):
  - archive's button stays 'Add Local Directory' + addDirectoryBtn
    id — semantically different from the others (archive
    accumulates multiple directories; everyone else operates on
    one). The naming reflects that.
  - transmittal — different layout entirely (page-header with
    sender/receiver logo cells); not a candidate for app-header
    standardization.
2026-05-03 20:42:49 -05:00
d874643af5 release: v0.0.14 lockstep
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 3s
Build + deploy releases / build-and-deploy (push) Successful in 8s
Build + deploy releases / notify-chart-prod (push) Successful in 1s
2026-05-03 20:40:02 -05:00
424bf8e769 feat(browse): Phase 2 — preview popup, ZIP expansion, ext filter, breadcrumbs
Bundles Phase 2 polish + the user-requested header/breadcrumb work:

- Breadcrumbs replacing the plain currentPath span. Server mode
  renders linkified ancestor segments (each <a> navigates to that
  directory; the browser fetches browse.html, the new instance
  auto-loads the listing). FS-API mode renders the rootHandle name
  as a non-link (no ancestor handles to navigate). Both prefix the
  path with a 🏠 root icon. Trailing slash + bold-current segment
  match common file-explorer conventions.

- Subdued 'Select Directory' button in server mode. Once browse is
  serving a real directory listing, the local-folder switcher is
  available but visually quiet (btn--subtle: transparent, muted
  color). FS-API mode keeps the primary styling (it's how the user
  got there). New btn--subtle CSS class added to browse's tree.css.
  A refresh button (⟳) appears next to it in both modes; clicking
  it re-fetches the current root listing.

- Header consistency: browse now matches archive's header layout
  (refresh + help buttons in addition to theme on the right). Help
  is a placeholder for future help dialog wiring.

- File preview popup. Click a file row → opens a popup window with
  the file rendered. Plain types (PDF, HTML, image) load in
  iframes; TIFF + ZIP listings via shared/preview-lib.js's
  renderTiff / renderZipListing helpers; text via <pre>; unknown
  types → 'click Download' placeholder. Modifier-click (ctrl/cmd/
  shift) and middle-click still open the file in a new tab via the
  underlying <a target=_blank>. Single popup window is reused
  across multiple file clicks (matches archive's UX).

- ZIP inline expansion. .zip files have a chevron and act like
  folders in the tree. First expand fetches the zip bytes
  (server URL or FS handle or parent-zip read), parses with JSZip
  (auto-loaded from CDN), and synthesizes the entry tree. Nested
  directories within the zip lazy-expand on demand by re-walking
  the cached entry list at the right path prefix. Click on a
  zip-entry file opens the preview popup with bytes read from
  JSZip. Recursive expand-all skips zip archives by design — they
  can be very large, and explicit click-to-expand is safer.

- Extension multi-select filter. Toolbar now has a <select
  multiple> populated with extensions present in the current
  view. Filter is OR-of-selected; combined with the name filter
  it's AND-of-both. Folders pass through (so expanding a folder
  whose name doesn't match the ext filter still shows its file
  children that do match).
2026-05-03 20:39:49 -05:00
127163dfa2 release: v0.0.13 lockstep
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 3s
Build + deploy releases / build-and-deploy (push) Successful in 8s
Build + deploy releases / notify-chart-prod (push) Successful in 1s
2026-05-03 20:21:06 -05:00
7caf3ecf3f fix(browse): listing fetch + row height + recursive expand/collapse
Three issues from initial v0.0.12 dev/prod testing:

  1. Online listings empty.
     directory.go was missing Vary: Accept on its responses, so
     browser/CDN cached the HTML response (the embedded browse.html)
     and served it again when browse's JS later fetched the same URL
     with Accept: application/json. JSON parse failed, autoDetect
     returned null, empty state showed. Adds Vary: Accept on both
     branches and changes browse.html cache-control to no-cache so
     deployed updates land immediately.

  2. Top-level folder rows tall, shrink as subtree expands.
     The .browse-table had flex:1 in a flex column. <table> in flex
     doesn't reliably distribute height across rows — with few rows,
     each row stretched. Wrap the table in a div with overflow:auto
     and drop flex:1 from the table itself.

  3. Recursive expand/collapse.
     Shift-click (or alt-click) on a folder now expand-all or
     collapse-all its subtree. Plain click still toggles just that
     folder. Implementation: tree.expandSubtree() walks BFS, loading
     each level's children in parallel, re-rendering between levels
     so the user sees progress. tree.collapseSubtree() recursively
     marks the subtree collapsed (children stay loaded for instant
     re-expand).
2026-05-03 20:20:54 -05:00
d6448159fa release: v0.0.12 lockstep
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 3s
Build + deploy releases / build-and-deploy (push) Successful in 8s
Build + deploy releases / notify-chart-prod (push) Successful in 1s
2026-05-03 19:59:38 -05:00
fb13ff4fd8 feat(browse): generic directory listing tool — default at folder URLs
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
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).
2026-05-03 19:56:51 -05:00
1033d30ad9 fix(ci): notify-chart workflows push to Forgejo, not GitHub
The chart repo (BMCD/tnd-zddc-chart) is mirrored Forgejo→GitHub
one-way (we set this up so the chart matches the same canonical-
on-Forgejo pattern as the public repos). When notify-chart-prod
and notify-chart-dev pushed directly to GitHub, the bump landed
on GitHub but Forgejo never got it — and the next time Forgejo's
push-mirror ran, it force-overwrote GitHub's bump with Forgejo's
older state. Symptom: prod stuck at v0.0.9 even after auto-bump
appeared to succeed; manual investigation showed Chart.yaml
appVersion was actually still 0.0.10 (the previous manual bump
that DID land on Forgejo).

Fix: clone+push to Forgejo (git.varasys.io/BMCD/tnd-zddc-chart)
instead of GitHub. Forgejo's mirror replicates to GitHub on the
next sync — going through the canonical-Forgejo path keeps both
sides in sync. Uses a new CHART_FORGEJO_TOKEN secret (separate
from CHART_GITHUB_TOKEN, which is no longer needed for these
workflows but kept for any future direct-GitHub use case).
2026-05-03 19:39:48 -05:00
bf54651fb0 release: v0.0.11 lockstep
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 3s
Build + deploy releases / build-and-deploy (push) Successful in 8s
Build + deploy releases / notify-chart-prod (push) Successful in 3s
2026-05-03 19:03:05 -05:00
042884ac5d ci: notify-chart-prod also bumps chart develop on stable cut
Per the bake-in invariant: when no active beta exists, dev tracks
stable. Previously notify-chart-prod only bumped chart's main, so
a stable cut updated BMCD prod but left dev one cut behind until
the next beta or manual dispatch.

Now the job loops through {main, develop} and pushes the same
appVersion bump to both. Each branch push triggers its own BMCD
pipeline (pipeline-prod for main, pipeline-dev for develop), so
prod + dev both rebuild against the new ZDDC stable in parallel.
notify-chart-dev.yml continues to handle the beta-cut path
(advances develop ahead of main between stable cuts).
2026-05-03 19:02:53 -05:00
915ab8a87a fix(preview): make HTML iframe links navigate (zddc-server-backed archive)
User report: opening an .html file with a '../.archive/' hyperlink in
a new tab works (zddc-server intercepts and serves the right file),
but clicking the same link inside the file previewer does nothing.

Two combined causes:

  1. The previewer's iframe was loaded from a blob: URL (built from
     the file's bytes). Relative URLs in the iframe resolve relative
     to the blob URL — '../.archive/X.html' becomes 'blob:.../.archive/
     X.html', which is gibberish. The browser never sends a request to
     the server, so the .archive interception never fires.

  2. sandbox="" disables every iframe capability including popups,
     so even <a target=_blank> is silently swallowed.

Fix per tool:

  - archive (table.js): for HTML preview, use file.url (the real
    server URL) directly when available; fall back to blob only for
    File-System-Access-API mode where there's no server to intercept
    anyway. Now relative links in archived HTMLs resolve against the
    actual server origin and the .archive interception fires as
    designed. Sandbox loosens to allow-same-origin + allow-popups +
    allow-popups-to-escape-sandbox so resources within the iframe
    load and link clicks (default target / target=_blank / middle-
    click) work normally. allow-scripts is intentionally NOT set —
    archived HTML still cannot run JS in the popup's origin.

  - transmittal (files-preview.js) + classifier (preview.js): same
    sandbox loosening for consistency. These tools' files are
    typically local (FileSystemAccessAPI), so the file.url branch
    doesn't apply — relative URLs that depend on a server still
    won't resolve in local mode (intrinsic limitation, no server).

Tested behavior preserved:
  - PDFs: unchanged (no sandbox, browser's PDF viewer handles).
  - Images / docx / xlsx / tiff / zip / text: unchanged.
  - HTML in zddc-server-backed archive: relative '../.archive/' links
    now navigate the iframe to the correct target file.
2026-05-03 18:54:55 -05:00
2820dffeaa fix(ci): single-line commit messages in notify workflows (YAML pipe block)
Multi-line git commit message bodies broke YAML parsing — pipe blocks
end on unindented lines, so the body lines starting at column 0 were
being interpreted by Forgejo's YAML parser as keys, yielding:

  yaml: line 158: could not find expected ':'

Switch to repeated `-m` flags (one per paragraph). Same end result
in git log; valid YAML.
2026-05-03 18:38:25 -05:00
b47b5222af fix(build): remove skip-if-unchanged in lockstep stable cut
The skip-if-no-source-changes-since-latest-tag check in
promote_release was a relic of per-tool independent versioning.
In the lockstep era it actively breaks CI re-cuts at a tag commit:

  - HEAD is at the v0.0.10 release commit
  - latest archive-v* tag is archive-v0.0.10 (== HEAD)
  - git diff archive-v0.0.10 HEAD = empty
  - SKIP archive promote → no archive_v0.0.10.html written
  - dist/release-output/ stays at whatever was seeded from
    /srv/zddc/releases/ (i.e. v0.0.9 from the previous deploy)
  - ./deploy --releases rsyncs that → live site STAYS at v0.0.9

Symptom: tag-triggered Forgejo deploy-release.yml workflow runs
(run 16) reports success but /srv/zddc/releases/archive_stable.html
still points at archive_v0.0.9.html.

Fix: always run _promote_stable for every tool on a stable cut.
The bytes written are deterministic at the same source, so
overwriting an existing per-version file is a no-op on disk —
the actual work the cut performs is advancing the symlink chain
(_v<X.Y>, _v<X>, _stable, _beta, _alpha) to the new version.
2026-05-03 18:37:03 -05:00
2f9f26a544 ci: auto-bump tnd-zddc-chart appVersion on ZDDC cut
Closes the loop on the user-described workflow:

  1. Iterate on tools / cut alpha → no chart involvement.
  2. `./build beta` → embedded/ commits to ZDDC main →
     notify-chart-dev.yml pushes a chart appVersion bump to
     burnsmcd/tnd-zddc-chart's develop branch → BMCD pipeline-dev
     fires automatically → dev image rebuilt with new beta bytes
     baked in.
  3. `./build release` → tag pushed → existing deploy-release.yml's
     new notify-chart-prod job pushes a chart appVersion bump to
     burnsmcd/tnd-zddc-chart's main branch → BMCD pipeline-prod
     fires automatically → prod image rebuilt with new stable bytes.

The chart repo IS still committed to (one Chart.yaml line, auto-
generated by either workflow), but no human ever touches it for
routine ZDDC releases. The chart commits are idempotent (skip if
appVersion already at target) and clearly marked as bot-generated.

The truly chart-commit-free version would require either (a)
BMCD's private helm-deploy-latest reusable to accept --set overrides
we'd compute, or (b) bypassing it entirely with our own helm step.
Both are deeper changes than this PR; this is the simplest reliable
solution within the existing reusable.

Auth: a new repo-scoped Forgejo Actions secret CHART_GITHUB_TOKEN
holds the classic GitHub PAT (already provisioned for the
Forgejo→GitHub mirror; same token, repo+workflow scopes,
SAML-SSO authorized for burnsmcd). The bot identity is
'ZDDC Release Bot <noreply@zddc.varasys.io>'.

Tested behavior:
- Workflow files are added by THIS commit. Pushing this commit
  does not fire either workflow (notify-chart-prod requires a
  tag; notify-chart-dev requires changes under
  zddc/internal/apps/embedded/). Safe to land before testing.
- First real test fires on the next ZDDC stable cut or beta cut.
2026-05-03 18:16:50 -05:00
f5ffd408f2 release: v0.0.10 lockstep
All checks were successful
Build + deploy releases / build-and-deploy (push) Successful in 8s
2026-05-03 17:11:46 -05:00
3494053421 fix(preview): render HTML files instead of showing literal source
HTML files in the file previewer (archive, transmittal, classifier
popups) were dispatched to the text renderer because 'html'/'htm'
are in shared/preview-lib.js's TEXT_EXTENSIONS (which is shared with
the syntax-highlighting code path). Result: opening an .html file in
preview showed its source as a <pre> block, not the rendered page.

Fix in each tool's popup builder + dispatcher:

  - Add 'html' / 'htm' to the iframe branch (alongside pdf), so the
    popup ships an <iframe src=blob:...> instead of an empty
    #previewContent div. The blob's MIME type from getMimeType()
    is already 'text/html', so the browser renders natively.
  - Skip the text-render dispatch for html/htm (the iframe is enough).
  - Add  to the HTML iframe so an arbitrary archived
    HTML file cannot run scripts, navigate top, submit forms, or
    open popups in the popup-window's origin. PDFs don't need this
    since the browser's PDF viewer is sandboxed natively.

classifier/js/preview.js uses a getPreviewType() switch instead of
chained ifs; adds 'html' as its own preview type (checked BEFORE
'text' since html is in TEXT_EXTENSIONS).

mdedit already handled HTML specially (file-tree.js has an isHtml
check); no change there.

TIFF was already rendered via the shared zddc.preview.renderTiff
canvas viewer in all four tools — no change needed for that path.
If TIFF preview appears broken on the live prod server, that's the
v0.0.9-alpha-baked-in image; the fresh stable redeploy fixes it.
2026-05-03 16:48:19 -05:00
8dbd002727 fix(build): commit embedded artifacts before tagging; alpha never bakes in
Two related fixes to the lockstep release flow + the project invariant
that prod must always run stable bytes (and dev only ever beta-or-stable).

1) tag-after-commit ordering. `./build release X.Y.Z` previously
   regenerated zddc/internal/apps/embedded/* with stable labels but
   tagged BEFORE folding those changes in. The tag landed on the
   source-side commit (alpha-dirty embedded), and the operator was
   expected to commit the embedded changes as a follow-up — which got
   dropped in practice, leaving prod binaries with alpha-dirty bytes
   baked in. (See the v0.0.9 re-anchor in the immediately preceding
   commit for the manifestation.)

   Refactor:
   - _promote_stable / promote_zddc_server in shared/build-lib.sh
     no longer call `git tag`. They keep their pre-flight check
     (now: tag must be in HEAD's history rather than == HEAD, since
     HEAD will advance after the release commit).
   - Top-level ./build adds a new "Release commit + tag" block at
     the end of stable cuts: stages the regenerated embedded files,
     makes a `release: vX.Y.Z lockstep` commit, and tags all seven
     artifacts at the new commit. Idempotent — no commit if there
     are no changes.

2) bake-in invariant. Plain `./build` and `./build alpha` now
   leave zddc/internal/apps/embedded/ untouched — the binary keeps
   shipping whatever the last beta or stable cut wrote. `./build
   beta` and `./build release` are the only paths that update
   embedded bytes. Active dev iteration uses tool/dist/<tool>.html
   directly; the binary's embedded copy is the default fallback,
   not a workbench.

Verification on this commit:
  ./build       → embedded mtime unchanged, no "M" lines for embedded/
  ./build alpha → embedded mtime unchanged, no "M" lines for embedded/

Docs updated to match in CLAUDE.md "Things that bite" + AGENTS.md
"Releasing — lockstep" + the leading help text in ./build itself.
2026-05-03 16:44:39 -05:00