Commit graph

64 commits

Author SHA1 Message Date
a79cfd2f88 feat(zddc): EnsureCanonicalAncestors lazy-creates canonical folders on write
New helper pair:
  - ResolveCanonicalPath(fsRoot, target)              — case-fold path resolution, no side effects
  - EnsureCanonicalAncestors(fsRoot, target, email…)  — case-fold + MkdirAll + auto-own .zddc seeding

For each canonical position along the requested path the helpers
substitute on-disk casing (so /Project/working/foo lands in an existing
Working/ rather than a new sibling) and materialise missing
working/staging/archive/<party>/{mdl,incoming,received,issued}/ folders.
working/, staging/, and archive/<party>/incoming/ get a creator-owned
.zddc seeded automatically; received/, issued/, and mdl/ are created
without auto-own (WORM and data-store concerns respectively).
reviewing/ is rejected — purely virtual, never on disk.

Wired into the file API:
  - serveFilePut          — resolve before auth, ensure after auth
  - serveFileMkdir        — resolve before auth, ensure after auth, with
                            two auto-own checks (target-is-canonical OR
                            parent-is-canonical)
  - serveFileMove (POST)  — resolve src+dst, ensure dst before rename so
                            a move from working/<draft> →
                            archive/<recipient>/issued/<draft> creates
                            the per-party folders on the way in

7 new unit tests in zddc/internal/zddc/ensure_test.go cover lazy
creation, case-fold reuse, per-party incoming auto-own, WORM no-auto-own,
empty-principal skip, reviewing rejection, and traversal rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00
b7e1a4310b refactor(archive): use shared zddc.ParseTransmittalFolder
The transmittal-folder grammar was duplicated as a private regex inside
the archive package. Replace the local regex with calls to the shared
parser in zddc/internal/zddc/folder.go so the grammar lives in one
place and the upcoming staging→working mirror logic can reuse it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00
9f97bfab3e feat(zddc)!: per-party WORM + auto-own; case-fold tool availability
BREAKING CHANGE. Project-level Issued/Received/Incoming folders no
longer carry special semantics. WORM enforcement and auto-ownership
move to the per-party canonical layout:

  - WORM mask now triggers on archive/<party>/received/ and
    archive/<party>/issued/ (any case, any party)
  - Auto-own .zddc writes on first mkdir under working/, staging/,
    or archive/<party>/incoming/ (any case)

Predicate API:
  - IsAutoOwnPath(parentDir, fsRoot)  — replaces IsAutoOwnParent(name)
  - IsWormPath(requestPath)           — same name, new pattern
  - WormFolderLevelIndex unchanged signature, new pattern

Legacy SpecialFolderNames / AutoOwnFolderNames / WormFolderNames /
IsAutoOwnParent are deleted (no Deprecated: stubs — early-development
project, no back-compat to preserve).

Tool availability (apps/availability.go) is case-fold throughout:
  - mdedit:     descendants of working/
  - transmittal: descendants of staging/
  - classifier: descendants of working/, staging/, or
                archive/<party>/incoming/
Working/, WORKING/, working/ all match identically.

Test fixtures rewritten:
  - special_test.go: covers IsAutoOwnPath / IsWormPath /
    WormFolderLevelIndex / ResolveCanonical / canonical lists
  - availability_test.go: per-party rules, case-fold scenarios
  - fileapi_test.go: rolePermissionsTestSetup now seeds
    Project-X/archive/Acme/{incoming,issued,received}/ rather than
    Vendor/{Incoming,Issued,Received}/ at the project root

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00
a471de8788 refactor(zddc): extract writeAutoOwnZddc into zddc.WriteAutoOwnZddc
Pure refactor. The mkdir post-hook in handler/fileapi.go duplicated
zddc-package types; lifting the body into the package itself lets the
upcoming EnsureCanonicalAncestors helper share it without re-exposing
the file API's internals.

No behaviour change. The grant shape (creator email → rwcda + CreatedBy
audit field) and the atomic-write path through zddc.WriteFile are
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00
5fa5d13b10 feat(zddc): add ProjectRootFolders/PartyFolders + ResolveCanonical helper
Introduce the lowercase canonical folder model that the new auto-create
feature will key off:

  - ProjectRootFolders = [archive, working, staging, reviewing]
  - PartyFolders       = [mdl, incoming, received, issued]
  - AutoOwnCanonicalNames        = [working, staging, incoming]
  - VirtualOnlyCanonicalNames    = [reviewing]

ResolveCanonical(parentDir, logical) does a case-fold lookup against
os.ReadDir(parentDir) so a manually-created Working/ is reused rather
than shadowed by a new working/ sibling.

Pure addition. The existing SpecialFolderNames / AutoOwnFolderNames /
WormFolderNames are kept (now Deprecated:) so dependent packages keep
compiling until the predicate rewrite lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00
5363b5364c feat(zddc): add ParseTransmittalFolder + IsTrnOrSubTracking helpers
Extracts the YYYY-MM-DD_<tracking> (<status>) - <title> grammar into a
reusable parser in the zddc package, and exposes a tracking-type
predicate for -TRN- / -SUB- (case-fold). The transmittal-folder regex
was previously only inside archive/index.go where it captured just the
date; the new ParseTransmittalFolder also returns tracking, status, and
title so handlers can recognise transmittal envelopes for upcoming
staging↔working mirror logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:14:19 -05:00
f6dc9d557a chore(embedded): cut v0.0.17-beta
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 6s
2026-05-07 06:28:22 -05:00
a0f9fca95d feat(archive): canonicalize deep .archive URLs + permissions follow the file
The .archive virtual prefix is now project-scoped at exactly one URL
depth: any /<project>/<sub>/.../.archive/... gets a 301 to the
canonical /<project>/.archive/.... The dispatcher does this before
calling the handler; query strings are preserved (the browser handles
the fragment automatically). .archive is also GET/HEAD-only — anything
else returns 405 with Allow: GET, HEAD, ahead of the file API.

Why: offline-built HTML files reference siblings as
"../.archive/<tracking>.html" from arbitrary depths. All of those refs
should converge on a single stable URL per (project, tracking) so
external links and bookmarks don't fork by entry point.

Permissions now follow the resolved file, not .archive itself.
.archive is a virtual surface — it has no on-disk directory and no
.zddc of its own, so gating it as if it did is wrong. Two gates only:

  - Resolve: only the per-target file's ACL chain decides. A user
    explicitly allowed at one transmittal folder but denied at the
    project root can still fetch tracking numbers that resolve there.
    Per-target denial returns 404 (not 403) so existence doesn't leak.

  - Listing: filter entries by per-target ACL. If the project bucket
    has zero indexed entries → 404 (unknown / empty project, indistinguishable
    from a probe). If the bucket is non-empty but the caller can read
    no entries → 403 (existence-leak guard: don't confirm an inaccessible
    project's archive exists). Otherwise → 200 with the filtered subset.

The listing endpoint is now content-negotiated like ServeDirectory:
Accept: text/html serves the embedded `browse` SPA bytes (with the
embedded ETag and X-ZDDC-Source: embedded:browse); Accept:
application/json returns the JSON entry array (with content-hash ETag
and 304 short-circuit). Vary: Accept set on both. The browse SPA's
auto-detect path-fetch then renders the archive entries as a sortable,
filterable flat list at /<project>/.archive/.

ServeArchive's signature is now (cfg, idx, w, r, project, filename) —
the dispatcher hands the normalized project string in directly, so
projectFromContextPath is gone. Old behavior was to derive project
from contextPath inside the handler; with the upstream redirect that's
redundant and the handler's preconditions are simpler.

Tests: archivehandler_test.go rewritten around the new semantics;
added per-target-only resolve, project-root-deny + per-target-allow
rescue, listing 403/404 distinction, JSON/HTML content-negotiation,
and conditional GET. main_test.go gains TestDispatchArchiveRedirect
(deep paths, query preservation, already-canonical no-op) and
TestDispatchArchiveMethodGate (PUT/POST/DELETE → 405).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 06:28:07 -05:00
8cf1aa1002 release: v0.0.16 lockstep
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 6s
Build + deploy releases / build-and-deploy (push) Failing after 9s
Build + deploy releases / notify-chart-prod (push) Has been skipped
2026-05-06 09:41:01 -05:00
610b7ef65a feat(archive): periodic rescan + admin reindex endpoint
The fsnotify watcher only sees events the local kernel generates, so on
SMB/CIFS-backed roots (Azure Files) writes from any other client are
invisible — the archive index would silently miss them until pod
restart. Add two backstops:

1. Periodic full re-walk via Index.Rebuild on a configurable interval
   (--archive-rescan-interval / ZDDC_ARCHIVE_RESCAN_INTERVAL, default
   60s, 0 to disable). Atomically swaps ByProject under the existing
   RWMutex; concurrent reads stay safe.
2. Admin-only POST /.profile/reindex that triggers an immediate rebuild
   and returns {duration_ms, project_count, tracking_count}, for the
   "I just dropped 50 files and don't want to wait" case. Gated by
   IsAdmin with the same 404-on-non-admin pattern as the other admin
   sub-resources.

Tests: TestRebuild_PicksUpAddsAndDrops covers add+drop semantics and
returned counts; TestServeProfileReindexPOST covers the happy admin
path; matrix entries cover the gate (anonymous/non-admin → 404, admin
GET → 405 method-not-allowed since the route is POST-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:50:51 -05:00
748cc59ce3 chore(embedded): cut v0.0.16-beta
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
2026-05-05 20:48:22 -05:00
4e6473a5cd chore(embedded): cut v0.0.16-beta with tables tool
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
Refreshes //go:embed bytes off fe28a73. Dev image now ships the new
tables renderer (handler-local embed at handler/tables.html) plus
build-label refresh on the six cascade-served tools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:35:38 -05:00
fe28a73f59 feat(archive): serve in-place instead of redirecting (preserves #anchor links)
Resolved `.archive/<tracking>.html` URLs now serve the target file's
bytes inline via http.ServeFile with Cache-Control: no-cache, replacing
the previous 302 redirect to the per-transmittal URL.

Why: external links like `.archive/<tracking>.html#section` are meant
to track the latest revision. A redirect exposes the snapshot URL — any
forwarded link then pins to that snapshot instead of "latest." Serving
in-place keeps the `.archive/` URL stable as the resolver's "current"
target moves over time.

Cache-Control: no-cache is intentional. Each load revalidates against
the on-disk file's Last-Modified/ETag, so when a new revision lands the
resolver picks it and the browser refetches transparently.

ACL is unchanged: enforced on both the `.archive` context directory and
the resolved target file (per-target denial returns 404, not 403, to
avoid disclosing that a tracking number exists in a hidden subtree).

archivehandler_test.go status expectations updated 302 → 200; fixture
bodies adjusted for body-content verification of the in-place serve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:32:29 -05:00
9ca36f25d8 feat(tables): new sortable/filterable grid tool for directories of YAML files
Tables is the eighth HTML tool: a read-only tabular view over a
directory of YAML files declared via `tables:` in `.zddc`. Anchor use
case is the Master Deliverables List, where each row is one
`<tracking>.yaml` under `Archive/<Party>/MDL/`. Rows click through to
the existing form renderer for editing.

Schema (zddc/internal/zddc/file.go)
  - New `Tables map[string]string` on ZddcFile. Map key becomes the URL
    stem (`tables[MDL]` → `<dir>/MDL.table.html`); the value is a path
    relative to the .zddc pointing at a `*.table.yaml` spec describing
    columns + the rows directory. No upward cascade in v1 — each
    directory hosting a table declares it directly.

Server handler (zddc/internal/handler/tablehandler.go)
  - `RecognizeTableRequest` matches GET `/<dir>/<name>.table.html`
    against the cascade's `tables:` declarations. Dispatch routes
    table requests before the form-system intercept.
  - `ServeTable` ACL-gates with `policy.ActionRead` and serves the
    embedded `tables.html` template; client walks the directory itself
    via the listing JSON or FS Access API.
  - tables.html embedded via //go:embed — same pattern as form.html.

Frontend (tables/)
  - Vanilla JS: app/context/util/filters/sort/render/main modules.
  - Reads spec + row YAML files via window.zddc.source (HTTP polyfill
    or local FS handle); js-yaml 4.1.0 vendored in shared/vendor for
    client-side parsing.
  - Sample fixtures under tables/sample/ for local testing.

Build + CI
  - Lockstep build registers tables alongside the other 7 tools (HTML
    output, embed mirror, versions.txt, release-output, tags).
  - Playwright project added; `npx playwright test --project=tables`
    is part of `npm test`.

Drive-by: rename mdedit Playwright selectors `#select-directory` →
`#addDirectoryBtn` to fix three pre-existing failing tests.

Drive-by: ignore locally-built `zddc/zddc-server` binary so it doesn't
get accidentally staged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:32:01 -05:00
2b17c9f030 chore(embedded): cut v0.0.16-beta with file API + RBAC
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 6s
Refreshes the //go:embed bytes off 3115e38. Dev image (ZDDC_REF=main)
now ships the file API and verb-based RBAC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:59:51 -05:00
3115e388fc feat(server): authenticated CRUD + verb-based RBAC with WORM archive folders
Replaces the binary acl.allow/deny model with five permission verbs
(r/w/c/d/a) and first-class roles, and adds an authenticated file API
(PUT/DELETE/POST move/mkdir) so the HTML tools can edit-in-place over
HTTP. Closes the AC-3(7) and AC-6 federal-readiness gaps.

File API (zddc/internal/handler/fileapi.go)
  - PUT <new>      → action c
  - PUT <existing> → action w
  - PUT <.zddc>    → action a (CanEditZddc strict-ancestor rule)
  - DELETE         → action d
  - POST mkdir     → action c (auto-writes creator-owned .zddc when the
                     parent is Incoming/Working/Staging)
  - POST move      → action w on src + c on dst, atomic via os.Rename
  - Optional If-Match for optimistic concurrency, --max-write-bytes cap,
    audit log emits a structured file_write event per operation.

Permission model (zddc/internal/zddc/{acl,file,roles,cascade_mode}.go)
  - acl.permissions: { principal → verb-set } map; principals are email
    patterns or role names. Empty verb set is an explicit deny.
  - roles: { name → members } definitions, available at the level they
    declare and all descendants. Closer-to-leaf shadows ancestor.
  - Legacy acl.allow/deny still work; they fold into permissions at
    parse time (allow → "rwcd", deny → "").
  - Cascade walks leaf→root; first level with any matching entry wins;
    the union of matching verb sets at that level decides.
  - --cascade-mode=strict adds a root→leaf ancestor-deny pre-pass so an
    ancestor explicit-deny is absolute (NIST AC-6). Default delegated
    preserves the existing commercial behavior.

Special folders (zddc/internal/zddc/special.go)
  - Incoming / Working / Staging: mkdir auto-writes a .zddc into the new
    subdir granting created_by + that email rwcda directly. Same form
    operators write by hand; creator can edit it later to add others.
  - Issued / Received: server-enforced WORM split. Cascade grants
    inherited from above the WORM folder are masked to r only; grants
    placed at-or-below the WORM folder retain r,c. Operators grant
    write-once (cr) to the doc controller via an explicit .zddc at the
    Issued/Received folder. Admins exempt — only escape hatch.

Browser polyfill (shared/zddc-source.js)
  - HttpDirectoryHandle + HttpFileHandle implement the FS Access API
    surface (values, getFileHandle, createWritable, removeEntry,
    queryPermission/requestPermission) over zddc-server's listing JSON
    and file API. Existing tools written against showDirectoryPicker
    work unchanged.
  - detectServerRoot() returns { handle, status }: tools auto-load on
    HTTP, surface a clear "no permission to list" message on 403, and
    fall back to the welcome screen on 0.
  - classifier renames take the atomic POST move path on HTTP-backed
    handles; mdedit and transmittal route reads/writes through the
    polyfill so prior FS-API code paths cover both modes.

Tests
  - zddc/internal/zddc/{cascade_mode,roles,special,acl}_test.go cover
    delegated vs strict, role membership / shadowing / legacy fallback,
    WORM split semantics, verb-set parser round-trip.
  - zddc/internal/handler/fileapi_test.go now also covers role-based
    vendor scenarios, WORM blocking vendor & doc controller writes,
    explicit Issued .zddc unlocking the cr drop-box, admin bypass,
    auto-ownership on mkdir, and strict-mode lockouts.

Docs
  - ARCHITECTURE.md + zddc/README.md document the verb model, role
    syntax, special-folder behaviors, cascade-mode flag, and full file
    API surface. Federal-readiness gap analysis strikes AC-3(7) and
    AC-6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:58:04 -05:00
52dde0b014 feat(apps): also accept apps_pubkey: inline in root .zddc
Adds a second way to configure the apps signing pubkey alongside the
existing --apps-pubkey / ZDDC_APPS_PUBKEY (path-to-PEM-file) form: an
inline PEM block under apps_pubkey: in the root .zddc file. Resolution
order:

  1. --apps-pubkey / ZDDC_APPS_PUBKEY  (path)   ← env/flag wins
  2. apps_pubkey: inline PEM in root .zddc       ← second
  3. nothing                                      ← URL fetches refused

Honored only at the root .zddc — same trust-anchor treatment as the
existing admins: field. Subtree write authority cannot re-anchor
trust because subtree apps_pubkey: entries are ignored. (Same
unmarshal pattern as the rest of ZddcFile; the root-only enforcement
is in setupApps where we explicitly read filepath.Join(cfg.Root,
".zddc") rather than walking a chain.)

Why offer both: env/flag fits k8s + systemd deployment shapes where
the operator already manages a config volume and prefers env-based
plumbing. Inline-in-.zddc fits the "everything in one config file"
mental model and matches how operators already think about admins:
and acl:. Either ships a working URL-fetch-verify story; the choice
is operator preference.

Logged differently per source so operators can grep for which path
populated the key:
  apps signing pubkey loaded source=env/flag path=/path/to/pubkey.pem
  apps signing pubkey loaded source="root .zddc apps_pubkey"

Smoke-tested end-to-end: a root .zddc with inline apps_pubkey: PEM
block + apps: archive: <upstream-URL> + ZDDC_APPS_PUBKEY unset —
the server logs "loaded source=root .zddc apps_pubkey" at startup,
fetches the URL, verifies the .sig against the inline key, caches.
Tampering still rejects; missing .sig still rejects; everything that
worked yesterday still works.

Docs: env-var tables in zddc/README.md and AGENTS.md note the
inline alternative; the federal-readiness gap analysis subsection
on code signing now lists both paths in its resolution order; the
release-page "Verify your downloads" section mentions both for
operators.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 06:56:02 -05:00
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
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
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
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
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
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
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
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
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
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
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
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
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
b15382ba9d release: v0.0.9 lockstep — re-anchor to clean embedded artifacts
All checks were successful
Build + deploy releases / build-and-deploy (push) Successful in 7s
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.
2026-05-03 16:18:05 -05:00
a02a26d3c2 feat: form-data system v0 (sixth tool + zddc-server endpoints)
All checks were successful
Build + deploy releases / build-and-deploy (push) Successful in 8s
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>
2026-05-02 20:12:16 -05:00
7570fb7494 refactor: separate website repo + deploy-host model
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>
2026-05-02 09:14:40 -05:00
76e1e78c55 chore: ./build is dev-only; ./build alpha is the explicit deploy
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>
2026-05-02 08:29:58 -05:00
6167e99f3a chore: simplify CLI to ./build / ./build beta / ./build release
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>
2026-05-02 08:11:10 -05:00
76820fa8dd chore: split website out into orphan branch + worktree
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>
2026-05-02 07:52:20 -05:00
d688e20dad feat(releases): channel options in version dropdown, drop chip pills
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>
2026-05-01 21:19:59 -05:00
8c2e65e4a2 fix(mdedit): two-line ZDDC tree display + dark-mode editor contrast
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>
2026-05-01 21:09:46 -05:00