Commit graph

177 commits

Author SHA1 Message Date
bd8301d0f2 release: v0.0.19 lockstep
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 7s
Build + deploy releases / build-and-deploy (push) Successful in 17s
Build + deploy releases / notify-chart-prod (push) Successful in 8s
2026-05-20 10:45:29 -05:00
fac6e7f0d6 release: v0.0.18 lockstep
Some checks failed
Build + deploy releases / build-and-deploy (push) Failing after 12s
Build + deploy releases / notify-chart-prod (push) Has been skipped
2026-05-20 09:37:56 -05:00
bdd14609d1 build: simplify to stable + exact-version (drop alpha/beta as public concepts)
Releases publish only two things per tool now: a current-stable
canonical symlink and an immutable per-version file. No more channel
mirrors (_stable/_beta/_alpha) and no more partial-version pins
(_v<X.Y>, _v<X>) — those were debt from a release model that never
matched the project's actual usage.

The `./build beta` verb stays, but narrowed: it's an internal SHA
snapshot for the BMC dev chart pipeline (chart's appVersion pins to
"<X.Y.Z>-beta-<sha>" and the chart Dockerfile fetches that SHA from
git). No public artifact on /srv/zddc/releases/. The embedded/* +
chore commit produced by `./build beta` is the actual snapshot.

`./build alpha` is removed entirely.

build/build-lib.sh:
- Drop alpha verb; narrow beta verb to embedded regen + chore commit
- promote_release: stable cut writes <tool>_v<X.Y.Z>.html + <tool>.html
  symlink + <tool>.html.sig companion symlink; beta is a no-op
- promote_zddc_server: same shape — per-version binary +
  per-platform canonical symlink (zddc-server_<plat>) + .sig symlink
- write_zddc_server_stub: singular; emits per-version stubs +
  one canonical zddc-server.html for current stable
- Delete _promote_channel, verify_channel_links, _channel_is_active
- Seed-from-live now copies only per-version files + .sig + pubkey.pem
  (the canonical symlinks get rewritten by this cut; old layout files
  get cleaned by deploy's --delete-after)
- build_releases_index: dropdown simplified to "latest stable +
  pinned versions"; channels-explainer section removed; tool cards +
  CTA URLs point at canonical <tool>.html / zddc-server_<plat>;
  composer emits "stable" sentinel for `apps:` entries
- Fix the acl:{allow:[...]} footgun in the apps_pubkey example

apps.go:
- isValidChannelOrVersion: accept only "stable" + exact X.Y.Z
  (drop alpha/beta and partial pins v0.0/v0)
- normalizeChannel: same
- Resolve URL composition: stable → canonical <prefix>/<app>.html
  (no _stable_ suffix), exact-version → <prefix>/<app>_v<X.Y.Z>.html
- Tests rewritten to match (beta/alpha replaced with v0.0.4 / stable;
  a new TestParseSpec_RejectsLegacyChannelsAndPartialPins locks in
  that the removed forms now error)

browse/build.sh: gate promote_release on $is_release like every other
tool's build.sh (longstanding inconsistency that errored under the new
promote_release case-statement).

freshen-channel: deleted (no channels to freshen).

Net: -254 lines, all green on full `go test ./...`. Dev build verified
via `./build` (no-arg) — new label format "v<next>-dev · <ts> · <sha>".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:17:46 -05:00
69878532b0 release: v0.0.17 lockstep
Some checks failed
Build + deploy releases / build-and-deploy (push) Failing after 11s
Build + deploy releases / notify-chart-prod (push) Has been skipped
2026-05-19 10:46:42 -05:00
3b2280de7f test(handler): coverage for record audit + history flows
Adds history_test.go with eight cases exercising the record-write
orchestration path:
- CreateStampsAuditFields: PUT to a fresh mdl path → audit fields
  injected; response echoes the stamped YAML; no history dir yet.
- UpdateIncrementsRevisionAndArchivesPrior: second PUT archives
  the prior bytes under .history/<base>/<ts>-<sha8>.yaml, bumps
  revision, preserves created_*, chains previous_sha.
- ConflictPreservesHistory: 412 from stale If-Match leaves the live
  file untouched and writes NO history entry (the failed write must
  be a true no-op).
- ClientAuditFieldsStripped: client-supplied created_by / revision
  are silently overwritten by server values — anti-forgery test.
- FilenameMismatch: URL says ...-0002 but body composes to ...-0001
  → 422.
- LockedFieldRejected: posting type=SPC to an rsk row → 422 with
  /type error (rsk/ locks type=RSK via cascade).
- SSRHistoryAtPartyLevel: writes to archive/<party>/ssr.yaml put
  history at archive/<party>/.history/ssr/, NOT at
  archive/.history/<party>/.
- RollupCreate_AssignsRowAndComposesFilename: three POSTs to
  /project/rsk/form.html in two table-scope groups demonstrate the
  server picks up filename_format + row_field+row_scope_fields from
  the cascade, auto-assigns sequence row numbers per group, and
  composes the canonical filename.

Bug fix surfaced by the first test: composeFilename was eliding TWO
separators around an optional placeholder when one was correct.
"ACM-{phase?}-PRJ" with phase="" was producing "ACMPRJ" instead of
"ACM-PRJ". Now drops only the trailing separator from output and
lets the next iteration emit the connector.

Default-project-{mdl,rsk}.form.yaml updated: project-rollup MDL +
RSK schemas gained the six readOnly audit fields and the project-
rsk schema picked up the full table-tracking component shape (+
row) plus an enum-locked type=RSK. The required: list no longer
includes type for rsk schemas — the cascade's field_defaults
injects it after schema validation, and requiring it would 422
well-behaved clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:08:52 -05:00
d947f616d1 feat(forms): augment served schema with cascade field_codes + locks
Two extension fields added to jsonschema.Schema so server-injected
constraints survive the YAML→Schema→JSON round-trip:
- Pattern: regex hint for the form renderer (server-side validation
  for field_codes already runs via WriteWithHistory).
- ReadOnly: surfaces locked / audit fields as disabled in the UI.
- Labels: x-labels extension carrying human-readable display strings
  paired with enum keys (e.g. ACM → "Acme Inc"), so dropdowns can show
  "ACM — Acme Inc" rather than bare codes.

serveFormRender now calls augmentSchemaFromCascade after loading the
spec: per-field, it injects enum (from field_codes:codes), pattern
(from field_codes:pattern), readOnly (from records:locked), and
default (from records:field_defaults). The augmentation is
per-request and never touches the on-disk *.form.yaml — operators
who declare their own enum/pattern in the spec take precedence
(injection is "if absent").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:58:21 -05:00
d35809cfd8 feat(forms): cascade-driven filename composition + audit on row create
Schemas:
- default-mdl.form.yaml: declare the six readOnly audit fields
  (created_at/by, updated_at/by, revision, previous_sha) so the form
  UI renders them disabled. additionalProperties: false is preserved;
  WriteWithHistory strips any client-supplied values before validation.
- default-rsk.form.yaml: overhaul to reflect the new shape. Each row
  now carries the table-tracking components (originator/phase?/project/
  area?/discipline/type/sequence/suffix?) plus a server-assigned `row`
  field; type is enum-locked to RSK to mirror the cascade's locked: rule.
  Drops the old `id` field (D-001/R-001-style identifiers are now
  composed from the components and stored in the filename).
- default-ssr.form.yaml: append the six audit fields.

Handlers:
- serveFormCreateSSR routes the write through WriteWithHistory so
  audit fields are stamped on first create (revision=1, created_*=
  updated_*=request principal/now). ssr.yaml's identity stays the
  party folder name; no filename composition runs.
- serveFormCreateRollup now resolves the cascade at the row's parent
  folder and uses the matched records: entry's filename_format to
  compose the row filename from body fields. For RSK rows the rule
  carries row_field+row_scope_fields, so the server auto-assigns the
  next sequence (001, 002, ...) within the table-tracking group and
  injects it into the body before composition. Defaults from
  field_defaults: are injected where the client omitted them
  (type=RSK locks in via the locked: list). Falls back to the
  historical date+email naming only when no records: rule is in
  scope (covers deployments that override defaults.zddc.yaml without
  declaring their own records: entries).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:55:07 -05:00
882d5e4c86 feat(zddc-server): server-stamped audit + history for record YAMLs
Adds cascade-driven schema + immutable audit history for the three table-style
record stores (mdl, rsk, ssr). Two new .zddc top-level keys carry the rules:

- field_codes: discriminated-union vocabulary (kind: enum|pattern|free) for
  the components used to compose tracking-number filenames and constrain
  record bodies. Map-merge across the cascade, mirror of apps: semantics.
- records: per-pattern rules (filename_format, field_defaults, locked,
  row_field, row_scope_fields). Filename-pattern scoping lets the SSR rule
  live at the party-folder level without bleeding onto mdl/rsk siblings.

PUTs to record YAML files route through a new WriteWithHistory orchestrator
(internal/handler/history.go) which:
- strips six client-supplied audit fields (created_at/by, updated_at/by,
  revision, previous_sha) so the client can't forge them
- validates body values against the cascade-resolved field_codes
- enforces filename_format composition (URL basename must match body fields)
- checks locked: defaults (422 mismatch)
- archives prior bytes to <dir>/.history/<base>/<RFC3339Nano>-<sha8>.<ext>
- stamps server-managed audit fields and writes the live file

History-before-live ordering preserves the prior version even on mid-write
crash. previous_sha forms a hash chain across revisions for tamper evidence.

The embedded defaults.zddc.yaml now declares records: entries for mdl, rsk,
and ssr.yaml. RSK rows carry the table-tracking components + row sequence
(filename = <table-tracking>-<row>); MDL rows compose to their own
tracking number; SSR records' identity is the party folder name.

GET <record>.yaml?history=1 returns a JSON list of prior revisions, ACL
gated identically to the live record. dot-segment rejection in
resolveTargetPath protects .history/ from direct client writes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:48:58 -05:00
f9ba493145 feat(tables): row context-menu opens the form, not raw YAML
Replace "Edit YAML" with "Edit row" — navigates to row.url, which
is already the schema-driven form-mode editor URL. The form handler
unwraps virtual-view URLs server-side so SSR and rollup rows route
to their per-party canonical paths automatically; no client-side
URL rewriting needed.

This fills the gap where row-click only opens the form for
complex-type cells (objects, arrays) — for plain scalars it enters
inline edit mode. Right-click → Edit row is now the discoverable
way to reach the full form for any row.

Raw YAML editing remains available via the browse tool directly
(navigate to the file's parent folder and click it in the tree).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:43:45 -05:00
1721b4b1db feat(tables): explicit Save button + clearer dirty-row marker
Three triggers for flushing pending edits:
  - Save button in the toolbar — shown only when ≥1 row is dirty,
    label reads "Save (N unsaved)". Disappears after a clean settle.
  - Ctrl+S (Cmd+S) anywhere on the page, capturing-phase so it beats
    the browser's "Save Page As" default.
  - focusout of #table-root with a relatedTarget outside the grid —
    catches "edit cell, click a header link, expect it to save".

The row-blur trigger stays — moving between rows still flushes. The
new triggers fill the gap when the user edits one row and then leaves
the grid entirely without first navigating to another row.

Dirty marker gets a 4px (was 3px) left swatch AND a faint blue
background tint on the row, so "unsaved" reads as a row state rather
than a small marker on the edge.

editor.setDraft / clearDraftField notify save.onDraftsChanged,
which refreshes the Save button + reapplies the dirty class.
saveRow on 200/201/202 also refreshes the button so it disappears
the moment its row settles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:38:35 -05:00
1604b62477 feat(tables): Edit YAML row-context menu item
Opens the row's backing .yaml in the browse tool's YAML editor
(preview-yaml.js — CodeMirror with syntax highlight, lint, Ctrl+S
save). Disabled on multi-row range and unsaved draft rows.

Three URL shapes resolve correctly:
  per-party row → <dir>/?file=<file>.yaml
  SSR virtual   → /<project>/archive/<party>/?file=ssr.yaml
  rollup virtual → /<project>/archive/<party>/<slot>/?file=<file>.yaml

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:31:17 -05:00
f3d334a221 feat(tables): rollup Add Row routes via the party column
The project-level MDL/RSK rollup specs lose `addable: false` and gain
a sibling form schema (default-project-{mdl,rsk}.form.yaml) that
makes `party` a required field. + Add row on the rollup view is now
live: the user types the party name in the Package column, the
server reads `party` from the body, validates that
<project>/archive/<party>/ exists on disk, strips the field, and
writes the row into archive/<party>/<slot>/<date>-<email>.yaml. The
response Location is the synthetic <project>/<slot>/<party>__<file>.yaml
URL so the rollup table client swaps the draft URL cleanly.

Wrong party = 422 with a clear error pointing at the SSR view as the
place to create the folder first. No auto-creation here — the rollup
is for filing deliverables/risks against existing packages, not for
spinning up new ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:14:37 -05:00
cef7188a77 refactor(convert): wrapper-in-image owns the sandbox; Go just exec's binaries
The bwrap engine + OCI engine that lived in internal/convert/runner.go
both leak isolation policy into Go code. Replaced with a single image-
side wrapper that drop-in-shadows pandoc and chromium-browser on PATH.
zddc-server's only contract with the image is now "exec.Command(name,
args) gets you that tool's behavior" — sandboxing, resource caps, and
namespace setup live entirely in shell scripts shipped by the image.

Architecture:
- zddc/runtime/zddc-cgroup-init runs at container start. cgroup v2's
  "no internal processes" constraint forbids a cgroup from having both
  children and processes; the init script moves PID 1 into a child,
  enables +memory +pids in subtree_control, then exec's zddc-server.
  Best-effort: degrades cleanly to "no resource caps" if cgroupfs
  isn't writable.
- zddc/runtime/zddc-sandbox-exec is the per-call wrapper, symlinked
  from /usr/local/bin/{pandoc,chromium-browser}. Creates a transient
  cgroup v2 (memory.max + pids.max), then bubblewrap-sandboxes the
  real binary at /usr/bin/<name>: --unshare-all, --ro-bind /usr,
  --proc /proc, --tmpfs /tmp, --clearenv. Caller's scratch dir comes
  in via ZDDC_SCRATCH env and is bind-mounted at the SAME path so
  absolute paths round-trip unchanged.

Go simplifications (~250 lines net deletion):
- Runner interface: Run(ctx, binary, stdin, scratchDir, cmd) — no
  ToolSpec, no mount list, no engine concept. Single localRunner
  implementation; bwrapRunner + containerRunner both deleted.
- health.Probe just looks up pandoc + chromium on PATH; Capabilities
  drops engine kinds.
- Convert.go: ToHTML/ToPDF write to a per-call scratch dir under
  TMPDIR and pass absolute paths; the wrapper bind-mounts the dir.
  No more "/tpl" / "/pdf" mount-point indirection.
- Config drops --convert-pandoc-image, --convert-chromium-image,
  --convert-engine, --convert-podman-socket (OCI engine gone) and
  --convert-cpus (CPU caps don't apply in the new model — wall-clock
  + memory + pids is the cap set). Defaults raised to match the new
  caps the user authorized: mem 512→1024 MiB, pids 100→256,
  timeout 30→60 s.

Image:
- zddc/runtime.Containerfile builds the production runtime image
  (alpine + bubblewrap + pandoc + chromium + font-noto). Two
  COPY statements pull in the wrapper scripts; ln -s symlinks the
  shadow names.
- bitnest dev image mirrors this layout under /var/lib/zddc-dev-build/.

Container privilege required:
- Nested bwrap needs the outer container to permit user + mount
  namespace creation + MS_SLAVE on root. The default seccomp +
  AppArmor profiles block all of these. Quadlet adds:
    --cap-add=ALL
    --security-opt=seccomp=unconfined
    --security-opt=apparmor=unconfined
    --security-opt=unmask=ALL
  Helm chart sets the equivalent via securityContext (capabilities.
  add: SYS_ADMIN, seccompProfile.type: Unconfined, appArmorProfile.
  type: Unconfined). Trade-off documented in AGENTS.md: zddc-server
  RCE now has near-root power within the container, but the bind-
  mount layout still bounds blast radius; bwrap is the real boundary
  between zddc-server and untrusted markdown.

Tests: convert_test.go fully rewritten for the new Runner signature.
Drops TestBwrapArgs_* (functionality moved out of Go) and
TestImageTag (no more image refs). All 15 Go test packages green.

Verified live on bitnest: pandoc --version round-trip exits 0
through the wrapper; MD→DOCX produces a valid Word 2007+ file
end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:47:58 -05:00
847e082e6e feat(tables): Export CSV button in the table toolbar
Client-side download of the current view — filter + sort + column
order match what's on screen, values pass through util.formatCell so
dates / numbers / booleans render the same way they do in cells. RFC
4180 quoting; UTF-8 BOM so Excel detects encoding without an import
wizard. Sits next to "+ Add row" and shows for every table that
loaded with columns (no HTTP gate — the data is already in the
client), so MDL, RSK, SSR, and both project-level rollups all get
the affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:00:23 -05:00
73e34bed5e feat: per-party RSK + project-level SSR/MDL/RSK rollup tables
Adds the risk register as a sibling of MDL under archive/<party>/, and
three project-level virtual aggregations at <project>/{ssr,mdl,rsk}:

  - SSR aggregates archive/<party>/ssr.yaml; "+ Add row" materializes a
    new party folder (mkdir + auto-own .zddc + ssr.yaml). Renames go
    through X-ZDDC-Op: ssr-rename, which os.Rename's the party
    directory so every row inside follows. Party name doubles as the
    folder name (no opaque IDs) and is path-derived on read.

  - MDL/RSK rollups list every deliverable / every risk across all
    parties with a derived `party` column; "+ Add row" is suppressed
    because party affiliation is ambiguous in the aggregate view.

All four virtual roots are declared `virtual: true` in
defaults.zddc.yaml. Spec/form bytes come from six new embedded
defaults (default-rsk.*, default-ssr.*, default-project-{mdl,rsk}.*)
served via a generalized IsDefaultSpec/IsDefaultSpecAbs that replaces
the MDL-only recognizer. Listing synthesis lives in fs/tree.go;
ACL on each synthetic row evaluates against the canonical
archive/<party>/ chain so non-owners see rows read-only. PUT/DELETE
through virtual URLs rewrite to canonical paths in fileapi.go via
sibling-shape blocks that don't touch the ACL gate. SSR row DELETE
returns 405 (delete the party folder via the archive view).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:47:56 -05:00
da4754b6ef feat(convert): bwrap engine as production default
Replaces the always-spawn-an-OCI-container model with a per-call
bubblewrap sandbox. Pandoc and chromium binaries are baked into the
zddc-server runtime image; each conversion runs them under bwrap's
Linux-namespace isolation. No daemon, no socket, no privileged outer
container, no OCI image pull at conversion time.

Why: the OCI engine paid ≈ 350 MB image pulls + 400 MB persistent
storage + ~300 ms per-conversion startup, plus required either an
on-host daemon socket (zddc-RCE → host-RCE in one hop) or nested
container privileges. bwrap gets the same sandbox properties
(--unshare-all, ro-bind /usr, tmpfs /tmp, clearenv, no-network) at
~5 ms per call and zero external dependencies. This is the same
primitive Flatpak uses for every app launch — battle-tested at scale
for "untrusted-input, short-lived, isolated."

Runner abstraction:
- `Runner.Run` signature: image string → ToolSpec{Image, Binary}.
  Both fields populated by entry points; whichever engine is
  installed reads the one it needs.
- `bwrapRunner` (new): assembles bwrap argv via `buildBwrapArgs`
  helper (testable in isolation), spawns bwrap with the binary.
- `containerRunner` (renamed conceptually to "legacy fallback"):
  unchanged behavior, still reachable for hosts that prefer OCI
  containers per conversion.

Probe order in health.Probe: bwrap → podman → docker. First hit wins.
Engine kinds in Capabilities: "bwrap" | "podman" | "docker". The
no-engine error message now lists all three.

Config (cmd/zddc-server):
- new --convert-pandoc-binary  / ZDDC_CONVERT_PANDOC_BINARY  (default "pandoc")
- new --convert-chromium-binary / ZDDC_CONVERT_CHROMIUM_BINARY (default "chromium-browser")
- existing --convert-pandoc-image / --convert-chromium-image kept
  for the OCI engine, doc updated to clarify they only apply there.
- --convert-engine helptext lists bwrap first.

Images:
- New `zddc/runtime.Containerfile` — alpine + bubblewrap + pandoc-cli +
  chromium + font-noto. Documents build/publish workflow.
- helm/zddc-server-prod/values.yaml.example: runtimeImage default
  switched to a placeholder for the new bundled runtime image; bare
  alpine NO LONGER works for /.convert (clearly called out in the
  comment).
- bitnest dev: /var/lib/zddc-dev-build/Containerfile mirrors the
  production runtime image. Quadlet at /etc/containers/systemd/
  zddc.container drops the podman-socket mount (no longer needed)
  and sets ZDDC_CONVERT_ENGINE=bwrap explicitly to avoid silent
  downgrades if a stray podman ends up on PATH.

Tests:
- convert_test.go: fakeRunner / recordingRunner now record ToolSpec.
- New TestToolSpecPopulation pins that both Image and Binary are
  filled by every entry point.
- New TestBwrapArgs_SandboxFlagsPresent / MountTranslation /
  RejectsBadMountSpec lock in the bwrap argv shape — a refactor that
  drops a hardening flag or misroutes a mount fails this loud.

Docs:
- AGENTS.md § "Server-side document conversion" rewritten around
  the bwrap-first model with podman/docker as legacy fallbacks.
- ARCHITECTURE.md convert reference updated.
- internal/convert package doc reflects the two-engine probe order.

Verified end-to-end on bitnest: probe reports
  engine=bwrap pandoc_binary=pandoc chromium_binary=chromium-browser
on startup. All 15 Go test packages green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:42:28 -05:00
19566360a6 ui: fix admin-mode frame; drop project-stage strip
Three UI cleanups against the admin/browse chrome.

Red admin-mode frame (shared/elevation.css)
  Was: body { outline: 3px ... ; outline-offset: -3px } — an outline
  doesn't reflow content, so in tools that butt their content to the
  viewport edge (browse split-pane, archive grid) the frame painted
  on top of the first 3px of content.
  Now: body.is-elevated::after { position:fixed; inset:0; border:3px;
  pointer-events:none; z-index:9200 }. The frame lives in its own
  fixed layer above all content, so it never overlaps or steals
  clicks; content layout is unchanged.

Project-stage strip (Archive · Working · Staging · Reviewing)
  Low-value chrome. Removed entirely:
    - delete shared/nav.js + shared/nav.css
    - drop the include from every tool's build.sh
      (browse, transmittal, form, archive, landing, tables, classifier)
    - delete tests/nav.spec.js
    - rebuild tables.html (the //go:embed'd baked-in copy)
  Project navigation already happens through the directory tree in
  browse and the URL bar; the strip duplicated breadcrumb information
  without adding capability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:39:35 -05:00
cff840e225 test: lock down elevation gate, .zddc write matrix, audit-log attribution
Four targeted test suites that pin the invariants exercised by the
preceding audit refactor. Closes the coverage gaps identified after the
admin-decider consolidation and the .zddc write-path fix.

internal/policy/principal_test.go (NEW)
  TestAllowActionFromChainP_TruthTable — 11 cases × 5 actions = 55
    assertions covering every (elevated × admin-at-level × action)
    combination. Pins the IsActiveAdmin short-circuit: bypass requires
    BOTH (in admins) AND Elevated; elevation alone confers nothing;
    empty email never matches.
  TestAllowActionFromChainP_AdminScopeDepth — root admin reaches every
    path; subtree admin matches in their own subtree; subtree admin
    does NOT match in a sibling subtree (the chain doesn't carry
    sibling admins lists).
  TestAllowActionFromChainP_BypassWinsOverWorm — elevated admin
    escape hatch in WORM zones, plus the negative control that an
    un-elevated admin does NOT bypass WORM.

internal/handler/auth_invariants_test.go (appended)
  TestInvariant_ZddcPutMatrix — 16 sub-cases across (root / project /
    subtree .zddc) × (root admin / subtree admin / non-admin /
    anonymous) × (elevated / un-elevated). Locks down which principal
    can PUT which .zddc.
  TestInvariant_ZddcDeleteMatrix — 5 DELETE cases.
  TestInvariant_UnelevatedAdminNoSilentBypass — 14 anti-bypass probes:
    every (admin-flavour × probe-path) tuple where an un-elevated
    admin must 403. Single bypass leak → loud test failure.

cmd/zddc-server/main_test.go (appended)
  TestDispatchZddcWriteRouting — full dispatcher path coverage:
    GET/HEAD route to ServeZddcFile (YAML or virtual placeholder);
    PUT/DELETE route through the .zddc-leaf carve-out into
    ServeFileAPI; intermediate .zddc.d/ segments still 404 at the
    guard.

internal/handler/middleware_test.go (appended)
  TestAccessLog_ChainAdminLevelAttribution — 7 cases pinning the
    forensic record: root admin → chain_admin_level=0, subtree admin
    in scope → chain_admin_level=N, subtree admin out of scope → -1,
    un-elevated admin → -1, non-admin → -1, anonymous → -1.
    Cross-checks active_admin == (chain_admin_level >= 0) so a future
    refactor can't desync them.

92 new sub-cases total. Coverage delta on the policy package:
76.1% → 87.2%; AllowActionFromChainP 0% → 100%;
activeAdminForRequest 7% → 68%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:29:43 -05:00
f196205622 refactor(audit): pre-release cleanup pass
Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.

Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
  to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
  to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
  instead of allow/deny lists.

Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
  own their own .zddc; the policy decider's IsActiveAdmin short-circuit
  is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
  same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
  true after the retirement). Profile page renders AdminSubtrees
  directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
  IsAdminForChain — no production caller passed true.

Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).

ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
  branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
  InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
  GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
  EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
  MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
  /.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
  "deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
  is the parent-deny-is-absolute variant. The in-process Go evaluator
  implements only the commercial cascade.

Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
  .zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.

.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
  .zddc out of the dot-prefix guard so PUT/DELETE/POST reach
  ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
  the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
  API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
  (matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
  parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
  body is designed to materialize on PUT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:28:07 -05:00
ae105fde1c feat(audit): chain_admin_level field in access log
The audit log now records WHICH chain level conferred admin
authority on each request — 0 for root super-admin, N for a
subtree admin at depth N, -1 for no admin authority. Forensics can
now distinguish:

  elevated=true active_admin=true chain_admin_level=0
    → root super-admin acting
  elevated=true active_admin=true chain_admin_level=3
    → subtree admin at /<project>/<sub>/<dir>/.zddc acting
  elevated=true active_admin=false chain_admin_level=-1
    → opted into admin but no grant on this path (out of scope)

New helper zddc.AdminLevelInChain returns the level index (or -1);
IsAdminForChain becomes a thin wrapper. Middleware's
activeAdminForRequest is rewired to return the level so the audit
emission gets the attribution without double-walking the cascade.

Pre-existing TestServeProfileProjectsCreate's "no .zddc unless body
supplies fields" expectation flipped — the project-create flow now
always seeds admins: [creator] so the test asserts the new
contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:55:53 -05:00
df19a63853 refactor(policy): drop strict-ancestor rule for .zddc edits
The rule said: an admin granted in /<dir>/.zddc can edit deeper
.zddc files but NOT the one that grants their own authority.
Intended to prevent self-elevation, peer-addition, and delegator-
removal.

Three problems:

- "Add peers" isn't an attack — it's the common collaboration case.
  Project creator can't grant a teammate access without bothering a
  super-admin every time.
- "Remove the delegator" doesn't work. Root admin authority lives
  in the ROOT .zddc and cascades down regardless of what's in
  /<dir>/.zddc; subtree admins can't touch it.
- "Self-elevation" within a subtree is meaningless. They already
  have rwcda there.

Replacement model: admins in /<dir>/.zddc OWN /<dir>/ and everything
beneath, including the .zddc itself. They can add collaborators,
modify ACLs, even remove themselves. Self-removal is a recoverable
footgun — root super-admins always retain authority via the root
cascade and can restore.

What stays:
- The admins: field as a load-bearing key (drives IsActiveAdmin
  + sudo-style elevation + WORM bypass).
- Bootstrap via root .zddc hand-editing.
- IsAdminForChain(chain, email, excludeLeaf bool) signature —
  ModeStrict / NIST AC-6 deployments can still opt into the strict-
  ancestor walk if they need it.

Tests flipped to match the new contract; ProjectCreate flow now
gives the creator real control over their project root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:47:04 -05:00
b80b11c99f feat: project creation gated by cascade ActionCreate, not hardcoded admin
The /.profile/projects endpoint previously refused anyone without
hasAnyAdminScope. Now it runs the standard decider with ActionCreate
on the parent directory — super-admins still pass via the
IsActiveAdmin bypass branch, and anyone the root .zddc grants `c`
to (e.g. `*@example.com: c`) can self-service a project without
needing an existing admin grant.

Other changes in this commit:

- The new project's .zddc is seeded with the creator's email in
  admins: when the request body doesn't supply one — they become
  subtree admin of their own project at birth. .zddc edits in
  deeper subfolders flow through their authority; strict-ancestor
  rule still prevents them from editing /<project>/.zddc itself.

- AccessView gains can_create_project, computed by the same decider
  call the endpoint uses — UI and server agree on visibility with
  no daylight.

- Profile page splits the subtree-admin template from the create-
  project template so the latter mounts on can_create_project,
  independent of has_any_admin_scope. Non-admin grantees see the
  form; admins keep seeing both.

- Lock-in tests cover the five interesting cases: cascade-granted
  user succeeds and becomes subtree admin; stranger gets 404;
  elevated super-admin auto-defaults admins; explicit admins list
  wins over the default; duplicate-name 409.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:25:19 -05:00
fd4f03afc3 fix(policy): read-path ACL honors admin bypass via AllowFromChainP
Reads (apps resolution, directory listing, file GET, archive index,
profile pages, subtree zip, form render) used policy.AllowFromChain
with email — no admin-bypass branch fired even for elevated admins,
because IsActiveAdmin only landed in AllowActionFromChainP.

Symptom: elevated admin navigating to /browse.html got 403 because
the root cascade has no explicit read grants in my refactored root
.zddc (role memberships + admins only; no acl.permissions). The
app-resolution path's AllowFromChain didn't see admin status.

Fix: new policy.AllowFromChainP that forwards to
AllowActionFromChainP(action=read). Migrate every read-path caller
to the principal-aware variant. The decider's single bypass branch
now fires uniformly across read and write decisions.

Migrated:
  cmd/zddc-server/main.go        (9 sites)
  handler/directory.go           (1)
  handler/archivehandler.go      (2)
  handler/zddcfile.go            (1)
  handler/formhandler.go         (3)
  handler/projectshandler.go     (1; EnumerateProjects sig takes Principal)
  handler/subtreezip.go          (1)
  fs/tree.go                     (1; uses already-built principal)

profilehandler.go:400 stays on AllowFromChain — it probes ACL for a
DIFFERENT email (the enumeration target, not the request principal),
so admin bypass on the request's principal doesn't apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:54:46 -05:00
55328c8c28 feat(browse): editors honor server-side write authority + don't steal focus
Listing JSON gains a writable bool per file row, computed by running
the policy decider with ActionWrite against the parent-dir chain
(with the same admin-bypass branch the file API uses). Cost: one
extra decider call per file in the listing, sharing the parent
chain so the cascade walk is amortized.

Browse loader stores writable on every tree node. The markdown and
YAML editors read it and gate their canSave + initial mount:

- !writable markdown → Toast UI Viewer (rendered, no edit toolbar,
  no caret). Banner above explains why save is disabled.
- !writable YAML → CodeMirror readOnly:'nocursor' (selection for
  copy, no caret). Banner above explains why save is disabled.

Both editors gain autofocus:false so keyboard nav in the browse
tree doesn't divert into the editor — arrow keys keep moving through
files and folders without the caret jumping. User clicks (or tabs)
into the editor when they actually want to type.

.zddc files already route through preview-yaml's isZddcFile path;
bare .zddc (no ext) matches because that function checks the
literal name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:42:36 -05:00
ded9ff7883 refactor(handler): adminOnly helper for /.profile admin gates
Five identical 'if !zddc.IsAdmin { 404 }' guards on /whoami /config
/logs /effective-policy /reindex collapse to a single adminOnly
closure inside ServeProfile. Behavior unchanged — same 404-leakage
property, same elevation-gated authority — just one site to audit
instead of five.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:29:23 -05:00
a85b25ce08 feat(handler): audit log records active_admin alongside elevated
The access log now reports whether the elevated user actually held
admin authority on the request's target path — i.e., whether the
single bypass branch in policy.InternalDecider.Allow would have
fired here. Three states fall out:

  elevated=false, active_admin=false: normal user
  elevated=true,  active_admin=false: opted into admin but no admin
                                       grant on this path (subtree-
                                       admin out of scope)
  elevated=true,  active_admin=true:  admin authority active for
                                       this path — WORM/ACL bypass

Implementation: AccessLogMiddleware gains a cfg parameter and calls
activeAdminForRequest at log emission, walking the closest existing
ancestor (same logic the file API uses to build its ACL chain).
The cascade is mtime-cached upstream so the per-request cost is one
map lookup in the common case.

Audit value: a reviewer can spot at a glance whether a destructive
write was authorized by ACL or by admin bypass. Plus "elevated=true
active_admin=false" rows surface users who tried to elevate outside
their actual scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:26:13 -05:00
6c818648ca refactor(handler): migrate authorizeAction + plan-review preflight to single bypass
authorizeAction (file API) and executePlanReview both used to make
their own IsAdmin / IsSubtreeAdmin / CanEditZddc calls before falling
through to the decider. After this commit every admin/elevation
branch is in policy.InternalDecider.Allow — the handlers just call
AllowActionFromChainP with the principal and let the decider decide.

fileapi.go authorizeAction:
- ~60 lines → ~20 lines.
- Three early-outs (IsAdmin / IsSubtreeAdmin / CanEditZddc) removed.
- .zddc strict-ancestor rule preserved: AllowActionFromChainP detects
  action == ActionAdmin (serveFilePut tags .zddc writes that way) and
  applies excludeLeaf=true to IsAdminForChain.

planreview.go executePlanReview:
- Two preflight checks now flow through AllowActionFromChainP.
- The "is admin OR is subtree admin? else fall through to decider"
  braid collapses to one decider call per target.
- Behavior preserved: subtree-admin authority required for the
  reviewing/staging workflow roots (strict-ancestor via ActionAdmin),
  WORM-cr authority required for received/<tracking>/ creation.

Plan Review and Accept Transmittal tests still pass, lock-in
invariants still hold (un-elevated admin denied, elevated admin
bypasses, subtree scope, strict-ancestor, etc.).

Next: remove the now-dead IsAdmin / IsSubtreeAdmin / CanEditZddc
helpers (still referenced by profilehandler and authcheck), or keep
them — they're not on a hot path and the migration there is its own
commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:20:32 -05:00
cfa7732183 test(handler): lock-in invariants for admin/elevation/WORM behavior
Baseline test battery that pins the current auth-decision behavior so
the upcoming consolidation refactor (single bypass site in
InternalDecider.Allow) is validated against a green baseline.

Each test names one invariant; failure messages identify exactly
which property regressed. Coverage:

- Un-elevated admin cannot bypass WORM (PUT to issued/ → 403).
- Un-elevated admin cannot edit .zddc (Principal.gate() blocks).
- Elevated admin bypasses WORM (positive control).
- Elevated subtree admin writes within scope, blocked outside it.
- Strict-ancestor rule: subtree admin cannot edit own subtree's
  .zddc, can edit deeper .zddc.
- Empty email never matches.
- WORM cr survives for un-elevated document_controller (create OK,
  overwrite still stripped).
- project_team has read-only outside their auto-own home.
- Forward-auth /.auth/admin gates strictly on ROOT admins:.

wormbypass_test.go retained as the original repro of the live bitnest
observation (un-elevated user write succeeded under --no-auth=1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:12:37 -05:00
1d758780fe feat(elevation): page-wide armed chrome when admin mode is on
The header toggle alone is easy to miss — admin elevation bypasses
WORM zones and ACL silently, so an admin who forgot they were
elevated could write into received/ or issued/ thinking they were
operating under their normal grants.

Two reinforcing affordances when the zddc-elevate cookie is set:

- body.is-elevated paints a 3px red outline around the entire page,
  visible from any scroll position and inside any tool surface.
- A sticky red banner sits across the top with a pulsing dot, an
  explicit warning ("write access bypasses WORM and ACL safeguards"),
  and a one-click "Drop admin" button that clears the cookie + reloads
  so the user can disarm without hunting for the corner toggle.

Both render on every page load via shared/elevation.js — applies to
every tool that includes the elevation slot, plus any tool that loads
the shared bundle even without a toggle host (the iframed classifier
inside browse's grid mode, etc.). Wired before the access fetch so
the banner appears immediately instead of waiting on /.profile/access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:41:07 -05:00
690d185dc2 feat: reviewing/ lifecycle — Plan Review endpoint, virtual received window, browse context-menu workflows
Two layers shipped together since the second builds on the first.

LAYER 1 — reviewing/ + Plan Review scaffolding

- reviewing/ is now a real folder under each project, populated by the
  Plan Review composite endpoint. The old reviewing/ virtual aggregator
  handler is retired.
- POST /<project>/archive/<party>/received/<tracking>/ with X-ZDDC-Op:
  plan-review scaffolds physical workflow folders under reviewing_root
  and staging_root, each carrying .zddc.received_path pointing back at
  the canonical submittal. Idempotent re-runs match by received_path
  and re-converge the ACL.
- Virtual received window: when listing or writing under
  <workflow>/received/, the server resolves through the canonical
  archive/<party>/received/<tracking>/ via the workflow's
  .zddc.received_path. Writes get rewritten to
  <workflow>/<base>+C<n><suffix> so review comments land in the
  workflow folder and never touch the WORM archive.
- Cascade defaults declare on_plan_review per project so the
  reviewing_root and staging_root are configurable.

LAYER 2 — browse context-menu workflows

- Accept Transmittal: right-click a transmittal folder in
  archive/<party>/incoming/ → validates ZDDC folder + filename
  conformance, atomic-renames the folder to
  archive/<party>/received/<tracking>/ (WORM zone), and optionally
  chains into Plan Review in the same composite request. Re-acceptance
  with a different revision merges file-by-file; WORM forbids
  overwrite of an existing filename.
- Stage / Unstage: right-click files in working/<…>/ → "Stage to…"
  with picker of existing staging transmittal folders + inline
  "New transmittal folder…" create; right-click files in
  staging/<…>/ → "Unstage to working/" defaulting to the user's
  working/<email>/ home. Reuses the file-API move primitive.
- Create Transmittal folder: right-click the staging/ pane → prompts
  for a ZDDC-conforming folder name with live validation; mkdir,
  then navigate to the new folder URL where the transmittal tool
  serves the editor.
- Supporting infrastructure: new CanonicalFolderAt cascade lookup +
  X-ZDDC-Canonical-Folder response header so the browse SPA can
  scope-gate menu items without re-implementing the cascade
  client-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:08:04 -05:00
b4c0327f63 feat(tables): row editor — inline Add Row, Delete, multi-row paste, min row height
The cell-editor was already complete (drafts, row-blur saves, etag
concurrency, validation). This commit adds the missing row-level ops:

- "+ Add row" appends a draft row inline; first cell focused. Row-blur
  POSTs to <dir>/form.html (the existing form-create endpoint); 201
  swaps the synthetic id for the server-returned URL/ETag. Empty rows
  the user walks away from are silently discarded.
- Right-click a row → "Delete row" (or "Delete N rows" when a cell
  range spans multiple rows). DELETE the row YAML with If-Match; 412
  surfaces a conflict warning.
- Multi-row clipboard paste creates new rows for grid content that
  extends past the last existing row, instead of dropping cells past
  the end. Each new row saves via its own row-blur.
- Empty rows now have a 2.4em minimum height so a freshly-added row
  is visible. Without the floor it collapses to cell-padding (~8px)
  and looks like a divider line.

Server-side: no new endpoints. Form-create (POST <dir>/form.html →
201 + Location) and file-API DELETE carry the new client capabilities.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:07:28 -05:00
167a56dc07 refactor: virtual file extensions for subtree zip + MD conversion
Replace `?zip=1` / `?convert=docx|html|pdf` query forms with path-suffix
URLs that look like ordinary files. `<dir>.zip` and `<file>.docx` /
`.html` / `.pdf` are virtual files served by the dispatcher when stat
fails at the requested path AND the corresponding base resource exists:

  GET /Project-1/archive.zip          ← if archive/ is a real directory
  GET /Project-1/notes.docx           ← if notes.md exists

Real on-disk files always win — a genuine archive.zip in the tree
serves its bytes normally. The virtual forms only fire when nothing
real is there.

Why: the URL form lets clients emit plain <a href> without query-
string handling; `curl -O` writes a sensible filename; mirror tools
pick up the path through normal recursion; the protocol surface
becomes "every URL is a file". Bash + filesystem mental model.

Server:
- New helpers handler.RecognizeVirtualSubtreeZip /
  RecognizeVirtualConvert (in subtreezip.go and converthandler.go).
- Dispatcher's stat-fails branch checks them between IsDefaultMdlSpec
  and MatchAppHTML. ACL is enforced on the base resource (the source
  directory for zip, the .md source for convert).
- Three legacy query-form branches removed from main.go.

Client:
- browse/js/download.js: `dir + '.zip'` instead of `dir + '/?zip=1'`.
- browse/js/preview-markdown.js: convert anchor hrefs become
  `<mdUrl-minus-.md>.<fmt>` instead of `<mdUrl>?convert=<fmt>`.
- shared/zddc-source.js downloadConverted: same transform.

Tests: subtreezip_test.go test URLs cosmetically updated to the new
shape (the handler is exercised directly, so the URL is metadata only,
but the test reads better).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:23:37 -05:00
050902fa9e chore: elevation slot in every tool + docs + helper file splits + smell cleanup
Polish pass after the big refactor in 2d114fc.

== Header elevation slot propagated ==

shared/elevation.{js,css} surface a header checkbox for admins.
30-minute sudo-style cookie window (Max-Age=1800, SameSite=Lax).
Only renders when /.profile/access reports can_elevate=true; quiet
for non-admins. Slot added to all 7 tool templates and concat'd
into all 7 build.sh files; admin in any tool now sees the toggle.

Three text-rename ride-alongs in archive/classifier/transmittal
templates: "Add Local Directory" → "Use Local Directory" (the same
rename that landed in browse earlier in this branch).

== Docs ==

- CLAUDE.md gets an "Admin elevation is sudo-style" paragraph in
  the "Things that bite if you forget" section.
- AGENTS.md gets a dedicated "Admin elevation (sudo-style)" section
  alongside "Bearer tokens" — same depth as the existing auth docs.

== Helper file splits ==

The retired form editor's shared helpers got bundled into a single
zddc_admin.go in the cleanup; that name is now misleading. Split by
concern:

- admin_helpers.go: hasAnyAdminScope (the only admin-specific helper)
- paths.go: resolvePath, urlPathOf, chainDirs (URL ↔ filesystem path
  math — used by several profile / zddc-file handlers)
- profile_assets.go (renamed from zddc_admin_assets.go): custom CSS
  pipeline. URL renamed from /.profile/zddc/assets/ → /.profile/assets/
  since /.profile/zddc/ no longer hosts an editor.
- treeEntry moves to profilehandler.go (alongside AccessView, its
  only consumer).
- writeError moves to profileprojects.go (its only consumer).

== Smell cleanup ==

- zddc.HasAnyAdminGrant(fsRoot, email) — new elevation-independent
  primitive that walks the cascade and reports whether email is named
  in any admin: list anywhere. Replaces the synthetic-elevated probe
  hack in enumerateAccess (`Principal{Email, Elevated: true}` was
  "lying" to the elevation gate to ask what it would say). The handler's
  hasAnyAdminScope collapses to a 4-line wrapper that gates on
  p.Elevated and delegates.
- Access-log middleware records `elevated` per request, so forensics
  can distinguish "admin acting as user" from "admin exercising power."
- browse/js/app.js's ?file= deep link walks multi-segment paths. Each
  intermediate segment is matched + expanded; the leaf gets
  selected/previewed. Auto-shows hidden when any segment starts with
  . or _. Silently no-ops on unresolved segments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:15:41 -05:00
2d114fcb96 refactor: unified listing protocol + form-editor retirement + admin elevation
Three coordinated changes that share the same files. Common theme:
convention beats exception. Where the codebase had a bespoke wire shape
or a special-case route, replace it with the generic shape every other
client already speaks.

== Listing protocol ==

GET / Accept: application/json used to dispatch to a bespoke
ServeProjectList handler returning {name, url, title} per project — a
shape that diverged from every other directory's listing.FileInfo
response. Now:

- listing.FileInfo gains an optional `title` field (read from each
  directory's own .zddc title:). Generic clients (landing, browse)
  read the same shape from every URL.
- appfs.ListDirectory emits a virtual `.zddc` entry (is_dir:false,
  virtual:true) when no on-disk file exists at that path and the
  caller asked for ?hidden=1. Opens an editable view of the cascade
  defaults; PUT-saving its bytes materialises a real file.
- The bespoke GET / JSON branch in cmd/zddc-server/main.go is gone.
  The bare-root landing serve is Accept-gated: HTML requests get the
  landing tool (project picker), JSON requests fall through to
  ServeDirectory and get the generic listing.
- landing's fetchProjects filters the new generic shape (is_dir,
  strip trailing slash) — same pattern fetchParties already used at
  /<project>/archive/.

== Form editor retirement ==

`<dir>/.zddc.html` was a server-rendered form for editing per-directory
.zddc files (~900 LOC across zddceditor.go, zddchandler.go, zddc_assets.go).
Browse's YAML/CodeMirror editor (with .zddc-schema lint) already edits
the same files via the generic file-API. Two ways to edit the same data
is exception, not convention.

- Delete zddceditor.go, zddchandler.go, zddc_assets.go and tests.
- `/<dir>/.zddc.html` → 302 redirect to `/<dir>/?file=.zddc` (browse
  opens the .zddc in its editor pane).
- /.profile/zddc/* namespace deleted (REST API + assets sub-route).
- Profile page's "Editable .zddc files" list links to browse.
- ServeZddcFile's 405 message + virtual-body comment point at the
  browse URL instead of the dead form.

== Admin elevation (Principal model) ==

Sudo-style: admins are treated as normal users by default; opting into
admin powers is per-request and gated by a `zddc-elevate=1` cookie.

- zddc.Principal{Email, Elevated} replaces bare-email arguments on
  IsAdmin / IsSubtreeAdmin / CanEditZddc. The signature change makes
  the elevation gate compiler-enforced at every admin call site —
  audit-fragility is gone. The empty-email short-circuit is no longer
  load-bearing for elevation; Principal.gate() is the explicit check.
- handler.ACLMiddleware derives Elevated per request: bearer tokens
  are implicitly elevated (CLI clients can't toggle a cookie); browser
  sessions elevate only when zddc-elevate=1 is set. PrincipalFromContext(r)
  is the one-call-per-site bundling helper.
- Every admin-check call site updated to pass a Principal.
- /.auth/admin (forward_auth target for the dev-shell IDE) explicitly
  bypasses elevation with a synthetic-elevated Principal — different
  cookie scope than zddc-server origin, documented inline.
- AccessView gains CanElevate (elevation-independent "does this email
  have admin authority anywhere?") so the header toggle can render
  itself for an un-elevated admin who hasn't opted in yet.
- ServeProjectList is removed; ProjectInfo + EnumerateProjects stay
  for the profile page's server-rendered project list.
- MatchAppHTML stays — still used by main.go to route <dir>/<tool>.html
  URLs to the apps subsystem when no real file exists.
- Test helpers carry Elevated=true by default (matches the
  pre-elevation default; tests for the un-elevated gate use the
  explicit form).

Go tests pass across all 14 internal packages. Browse + every other
tool rebuilds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:15:07 -05:00
a62960b712 chore(embedded): cut v0.0.17-beta
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 7s
2026-05-13 14:45:52 -05:00
72c0552750 feat(browse): "Show hidden" toggle — list .-prefixed and _-prefixed entries
Adds a UI checkbox next to the existing Sort dropdown that surfaces
hidden entries when ACL would otherwise allow read. Default off
(matches today's filtered behavior). On toggle, browse re-fetches
the current directory with ?hidden=1 and re-renders.

  ┌─ browse toolbar ─────────────────────────────────────────────┐
  │  Sort: [Name (A→Z) ▾]    ☐ Show hidden                       │
  └──────────────────────────────────────────────────────────────┘

Server-side surface:

  - internal/fs/tree.go ListDirectory gains an `includeHidden bool`
    parameter. The .-prefix filter (previously hard-coded) now also
    drops _-prefix entries (matches dispatch's reserved-prefix guard)
    and honors the new flag.
  - internal/handler/directory.go reads `?hidden=1` from the request
    and threads it through.
  - cmd/zddc-server/main.go dispatcher relaxes its dot-prefix and
    _-prefix guards for GET/HEAD when `?hidden=1` is set, so clicking
    a hidden entry's link works. `_app/` (apps cache) stays
    unconditionally reserved — those bytes must go through the apps
    resolver. Writes to hidden paths stay blocked (the file API has
    its own segment check that the flag does NOT relax).
  - internal/listing/listing.go: signature parity (the lower-level
    helper that's used by tests + non-cascade listing paths).

Security model unchanged: the ACL chain on the parent dir is the only
real gate. Whoever can read the dir can see its contents — toggling
"Show hidden" just stops the client-side filter from masking
.-prefixed and _-prefixed entries. Hidden paths today:

  • <dir>/.zddc                ACL YAML — already exposed via /.profile/zddc
  • <dir>/.converted/<base>    cached MD→DOCX/HTML/PDF, same sensitivity as source
  • <root>/.zddc.d/tokens/     per-token metadata; filename = sha256(token)
                               so not bearer-usable. Default root ACL
                               restricts to admins; matches /.tokens UI.
  • <root>/.zddc.d/logs/       access logs; same admins-only audience
  • <root>/_app/               cached upstream tool HTML (public)
  • <root>/_template/          install.zip scaffolding (public)

None of these contain bearer credentials or secret material that the
existing ACL doesn't already gate. The walls are still the cascade.
2026-05-13 14:45:41 -05:00
9a5b293590 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-13 13:48:52 -05:00
1db9fd06e7 chore(embedded): cut v0.0.17-beta
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 7s
2026-05-13 13:10:12 -05:00
59d8ccf0fc 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-13 13:06:55 -05:00
95c6feed16 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-13 12:55:21 -05:00
52a6f139bb 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-13 12:17:59 -05:00
f37b55ddd5 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-13 12:07:08 -05:00
ab552c8c1b 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-13 11:14:52 -05:00
320c5d09ab 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-13 10:34:56 -05:00
e7f6334daa chore: retire mdedit tool — markdown editor lives in browse now
mdedit/ is gone. Its functionality moved into browse's preview plugin
(browse/js/preview-markdown.js) — YAML front matter editing, outline,
and on-demand DOCX/HTML/PDF download all happen there. Browse is the
default_tool for working/ + reviewing/ as of the previous commit, so
existing URLs of the form /<project>/working land on browse without
operator action.

Removed:

  • mdedit/ source tree (Toast UI app, CSS, JS, template, build.sh)
  • zddc/internal/apps/embedded/mdedit.html (//go:embed blob)
  • tests/mdedit.spec.js + the "mdedit" project in playwright.config.js
  • mdedit entries in zddc/internal/apps/embed.go (//go:embed, var,
    switch case in EmbeddedBytes)
  • "mdedit" in zddc/internal/zddc/validate.go AppNames + the matching
    error-message app list
  • "mdedit.html" branch in zddc/internal/apps/handler.go MatchAppHTML
  • mdedit case in tests (handler_test.go, validate_test.go,
    zddchandler_test.go) — test fixtures now use browse/classifier
  • mdedit from build (per-tool build.sh loop, tool-list literals,
    composer cards) and shared/build-lib.sh ZDDC_RELEASE_TOOLS
  • mdedit from freshen-channel's tool list and usage banner
  • mdedit-specific paragraphs in AGENTS.md and ARCHITECTURE.md;
    Markdown Editor section in ARCHITECTURE.md rewritten to point at
    browse/js/preview-markdown.js
  • mdedit from CLAUDE.md, README.md, zddc/README.md tool lists

Historical mdedit_v*.html / mdedit_v*.html.sig files in
/srv/zddc/releases/ on the deploy host are immutable history — they
stay where they are. The next ./build release cut will simply not
produce new mdedit_v* artifacts.
2026-05-13 10:34:31 -05:00
b5aab81d31 feat(zddc): MD→{docx,html,pdf} server-side conversion via stock pandoc + chromium containers
New endpoint GET /<path>/foo.md?convert=docx|html|pdf renders a markdown
source on demand. Surfaced as the Download buttons in browse's markdown
editor (separate commit).

Execution model — two upstream container images, lazy-pulled:

  • docker.io/pandoc/latex:latest  — MD→DOCX, MD→HTML (entrypoint pandoc)
  • docker.io/zenika/alpine-chrome — HTML→PDF (entrypoint chromium-browser)

No custom image build. The runner passes --pull=missing on every podman/
docker invocation so the operator only needs the runtime installed —
first request pulls the image, subsequent requests use the local cache.
Overrides: --convert-pandoc-image / --convert-chromium-image (and the
matching ZDDC_CONVERT_* env vars). Engine: --convert-engine (podman
preferred, docker fallback). Resource caps: --convert-mem-mib (512),
--convert-cpus (2), --convert-pids (100), --convert-timeout (30s).

PDF flow is two-stage: pandoc renders the markdown through the embedded
viewer-template.html to standalone HTML, then chromium prints that HTML
via --print-to-pdf. Preserves the print-media CSS already authored in
viewer-template.html rather than going through pandoc's LaTeX template.

Each conversion runs in a throw-away container with --rm --network=none
--read-only --tmpfs=/tmp --cap-drop=ALL --security-opt=no-new-privileges
--env=HOME=/tmp plus a bind-mounted scratch dir for I/O. Pandoc reads
markdown from stdin / writes to stdout; the viewer template lives at
/tpl (ro). Chromium reads HTML from a read-write bind mount at /pdf
and writes the PDF to the same mount; the host reads it back. No shell
wrappers, no shell quoting — argv flows straight into each image's
entrypoint.

On-disk cache at <dir>/.converted/<base>.<ext> with mtime synced to the
source. Fast path is a stat-and-serve with no exec; slow path
singleflights concurrent requests for the same target. PUT/DELETE/MOVE
on the source .md purges the .converted/ sidecars.

Per-project template variables (client/project/contractor/project_number)
come from a new .zddc `convert:` cascade block, walked leaf→root with
per-key latest-wins. Filename-derived variables (title, tracking_number,
revision, status, is_draft) come from a new zddc.ParseFilename helper.

If neither podman nor docker is on PATH, the endpoint serves 503 with
a clear Retry-After. The rest of the server keeps working.

This is the first os/exec site in the codebase. The hardening in
internal/convert/runner.go — context.CancelFunc → process kill,
cmd.WaitDelay, platform-specific SysProcAttr (Setpgid + Pdeathsig on
Linux), minimal env, stdout cap via limitWriter, stderr ring buffer —
sets the pattern for any future shell-outs.

Public surface:
  convert.ToDocx(ctx, source, meta) / .ToHTML / .ToPDF
  convert.Probe(ctx, engineOverride) → install Runner if engine present
  convert.SetImages(pandoc, chromium)
  convert.ConfigureLimits(memMiB, cpus, pids, timeout)
  convert.Available()

Container handler at internal/handler/converthandler.go; dispatcher
branch in cmd/zddc-server/main.go inserts the convert lookup after the
existing ACL gate, reusing the source file's read policy verbatim.
2026-05-13 10:33:56 -05:00
ba7e7a3fdd chore(embedded): cut v0.0.17-beta 2026-05-12 13:25:44 -05:00
81e065e5b0 feat(zddc): GET /dir/?zip=1 — stream an ACL-filtered .zip of a subtree
zddc-server can now hand back a whole directory subtree as a single
streamed application/zip download: GET /some/dir/?zip=1 (works on both
/dir and /dir/) → Content-Type: application/zip + Content-Disposition:
attachment; filename="<dir>.zip", containing every readable file under
/some/dir/, recursively.

handler.ServeSubtreeZip walks the tree with filepath.WalkDir, ACL-gates
each file by the .zddc chain of its containing directory (per-dir
decision cache, same shape as serveArchiveListing), skips hidden
entries ("." and "_" prefixes — .zddc, _template, _app), and adds a
.zip *file* it encounters as opaque bytes (it does not recurse into it
— that's the navigable-virtual-surface feature, a different thing).
The response is streamed (zip.NewWriter straight onto the
ResponseWriter, Store for already-compressed extensions, Deflate
otherwise), so a fully-ACL-denied or empty subtree just yields a valid
empty zip rather than a 403 (a stream can't change status after the
headers go out; empty leaks no more than 403). HEAD sends the headers
and no body. The dispatch's directory ACL gate still runs first, so a
viewer who can't read the directory gets 403 before the handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:59:17 -05:00
5e4d4fefb3 feat(zddc): serve a .zip as a virtual directory (zipfs + dispatch intercept)
zddc-server can now browse into a .zip file without the client
downloading the whole archive:
  - GET …/Foo.zip/                → JSON listing of the zip's members
                                     (Accept: application/json), or the
                                     browse SPA (HTML) — same content
                                     negotiation as ServeDirectory/.archive
  - GET …/Foo.zip/sub/doc.pdf     → extracts and streams that one member
                                     (Range / ETag / conditional GET via
                                     http.ServeContent)
  - GET …/Foo.zip                 → unchanged: the raw .zip download
  - PUT/DELETE/POST …/Foo.zip/…   → 405 (zip access is read-only)

New internal/zipfs package reconstructs directory levels from the zip's
flat central directory (synthesising intermediate dirs with no explicit
"<dir>/" entry, mirroring what browse does client-side with JSZip) and
drops zip-slip-unsafe entries ("..", absolute, backslash). New
handler.ServeZip wraps it. The dispatcher gets splitZipPath + an
intercept placed before the file-API branch (so a write to a path under
a .zip is refused, not silently mkdir'd); ACL is the chain of the
directory CONTAINING the zip — a zip carries no .zddc of its own, same
as the .archive virtual surface. The os.Stat-per-segment walk is gated
by a cheap ".zip/" substring check so ordinary requests are unaffected.

Also fixes two pre-existing dispatch-test failures uncovered along the
way: a non-existent top-level "*.html" URL was 302'ing to its slash
form (because the bare "*" project glob makes every first-level segment
"declared") — the cascade-declared no-slash block now requires a
directory-shaped URL (trailing slash, or no file extension); and the
stale TestDispatchSlashRouting expectation that archive/<party>/mdl/
302s to mdl/table.html was updated to match the intended behaviour
(the default-MDL virtual fallback shows the browse listing there; only
a real on-disk tables: + *.table.yaml triggers the bounce).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:17:47 -05:00
bb5e059477 feat(zddc): dir_tool key — make the slash/no-slash routing convention configurable
The trailing-slash directory form was hardcoded to serve `browse`. Add a
`dir_tool` .zddc key (cascades leaf→root, floors at `browse`) so an
operator can point a subtree's slash form at another directory-oriented
tool — the symmetric counterpart to `default_tool` (the no-slash
"specialized app"). handler.ServeDirectory now resolves it via
zddc.DirToolAt; JSON listing requests are unaffected (raw listing
always served, so browse can still enumerate).

Also collapse the no-slash dispatch: the on-disk-directory and the
virtual-declared-path branches in main.go each carried their own copy
of "default_tool → tables-carveout-or-apps.Serve → 302", with
inconsistent ACL checks. Extract one chokepoint, serveSpecializedNoSlash,
that enforces ACL uniformly for every default_tool route.

Updates ARCHITECTURE.md and AGENTS.md: the stale "Special folders" /
hardcoded-availability sections now describe the .zddc-cascade model
(defaults.zddc.yaml, the schema-key table, the slash/no-slash
convention, WORM, standard roles).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:46:55 -05:00