Compare commits

...

34 commits

Author SHA1 Message Date
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
351dc63cb4 feat(zddc): ResolveVirtualView resolver for project-level table aggregations
Models virtualreceived.go's request-time path-rewrite pattern. Recognizes
/<project>/{ssr,mdl,rsk}/... URLs and maps row reads/writes back to
canonical files inside <project>/archive/<party>/, so ACL evaluates
against the per-party chain and operator overrides live where the data
does. ListSSRParties and ListRollupRows feed listing-time synthesis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:47:28 -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
85e6eb152c fix(browse): save-button gate reads canSave at click time
The markdown editor's save handlers (markDirty, save(), convertBtns
intercept) referenced a bare identifier `writable` that never existed
in their scope — the captured variable was named `writableMode`. JS
silently evaluates `!undefined` to true, so saveBtn.disabled stayed
true forever and Ctrl-S was a no-op. The download-as-* intercept
treated every dirty file as read-only and offered the "save a copy
elsewhere" toast.

YAML editor had the matching-name pattern (`writable` defined and
referenced) so the symptom was hidden, but the same stale-closure
shape: capture once at mount, never re-read when the underlying tree
node's writable bit changed.

Fix both: gating logic reads canSave(node) fresh at every click, not
from a closure. Mount-time captures stay for initial UI shape
(read-only banner, CodeMirror readOnly:'nocursor') where the decision
is correct at the moment it's applied.

Codify the pattern in AGENTS.md § "JS module pattern":
no bundler + no reactivity layer ⇒ closures don't refresh ⇒ read
fresh in handlers, never cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:08:47 -05:00
c240bf30a5 feat(browse): persist selection + show-hidden in URL
The browse SPA's URL bar now reflects the currently-selected node and
the show-hidden toggle, so:

  - bookmarking / copy-pasting the URL re-opens the same view
  - reload (forced by the admin-mode toggle, which has to reload to
    pick up the elevation cookie) lands the user back on the same
    selection with intermediates expanded
  - browser back/forward walks history correctly, re-applying both
    the scope AND the file/hidden state at each step

Implementation:
  events.js: syncURLToSelection() — builds <scope>/?file=<rel>&hidden=1
    via URLSearchParams (with %2F → '/' so the URL bar reads cleanly)
    and history.replaceState's it. Called from every selectedId set
    site (single-click, arrow-key nav, right-click), from the show-
    hidden toggle, and after rescopeServer's scope pushState so the
    new scope keeps the hidden flag.
  app.js bootstrap: reads ?hidden=1 in addition to the existing
    auto-flip-on-dotfile logic, so an explicit hidden toggle survives
    reload.
  app.js popstate: re-walks ?file= via openDeepLink so back/forward
    restore not just the scope but the selection + expansion path.
    Also re-applies hidden=1.

Choice: replaceState (not pushState) on selection changes — the only
"intentional" navigation step is the scope rescope (already pushState).
A long click sequence shouldn't pollute history.

What this doesn't cover: sibling folders the user expanded that aren't
on the path-to-selection. Persisting that needs sessionStorage; for
"as much as possible without overcomplicating" the URL-only state
captures scope, selected node, and the path-to-selection (auto-
expanded by the deep-link walker) — the most common case.

FS-API mode (offline / file://) is a no-op — no shareable URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:00:20 -05:00
8aebb0c346 fix(browse): propagate writable bit into tree nodes
Root cause of "I'm root admin but the editor says read-only."

loader.js parses the listing JSON and stamps `writable` onto the raw
entry. tree.js:newNode() then copies every other field (name, url,
isDir, size, modTime, ext, handle, virtual …) into the tree node —
but dropped `writable`. So `node.writable` was always undefined and
`canSave(node)` short-circuited to false, mounting the YAML and
markdown editors read-only even for an elevated admin where the
server had correctly stamped writable=true.

Symptom: red banner / read-only mode regardless of admin status.
Server-side log line was correct (elevated=true active_admin=true
chain_admin_level=0); the bit just never reached the editor.

One-line fix: include `writable: !!raw.writable` alongside `virtual`
in the tree-node initialiser. Verified end-to-end against the live
bitnest fixture — every entry now carries the bit through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:47:57 -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
63fc4338b6 fix(browse): trim markdown read-only banner + drop YAML front-matter placeholder
- Read-only markdown files mount as Toast UI Viewer, which already
  has no edit toolbar / no caret — the absence is itself the cue.
  Drop the explicit red banner; keep the disabled-Save tooltip.
- YAML front-matter textarea no longer shows a placeholder example
  (title/date/tags). A file without front matter renders as a
  genuinely empty pane instead of looking like it has content.
- YAML editor's banner stays — CodeMirror readOnly has no
  built-in visual signal beyond the disabled caret.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:47:21 -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
465d2f605c feat(policy): IsActiveAdmin field + AllowActionFromChainP entry point
Lays the rails for the consolidation refactor — the decider gains a
single admin-bypass branch at the top of InternalDecider.Allow, and a
new principal-aware entry point computes IsActiveAdmin from chain +
Principal.Elevated. No caller uses the new path yet, so behavior is
unchanged; lock-in tests stay green.

  AllowInput.User.IsActiveAdmin bool   // caller-computed bypass flag
  AllowActionFromChainP(ctx, d, chain, p, path, action) (bool, error)

The decider's branch:

  if input.User.IsActiveAdmin { return true, nil }

is the ONLY admin escape hatch in the package. Strict-ancestor rule
for .zddc edits is preserved inside AllowActionFromChainP via
IsAdminForChain(chain, email, excludeLeaf=true) when action==ActionAdmin.

Email-only entry points (AllowFromChain, AllowActionFromChain) leave
IsActiveAdmin=false implicitly — they're for read-path callers that
don't need admin bypass (directory listing, archive index, profile
read endpoints).

Next commits: migrate authorizeAction and plan-review's pre-flight
to AllowActionFromChainP, then delete the scattered IsAdmin/
IsSubtreeAdmin/CanEditZddc early-outs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:17:44 -05:00
1c0777a847 feat(zddc): IsAdminForChain — single helper for admin authority
Pure cascade-walk admin check that replaces IsAdmin (root only) +
IsSubtreeAdmin (cascading) + CanEditZddc (strict-ancestor) under one
signature once callers migrate.

  IsAdminForChain(chain, email, excludeLeaf bool) bool

- chain is built for the request path, so subtree-admin scope falls
  out naturally (a chain rooted at /foo/ will only surface admins:
  entries at root and any level up to /foo/).
- email "" never matches (anonymous refusal).
- excludeLeaf=true drops the deepest level — implements the strict-
  ancestor rule for .zddc edits. At chain length 1 (root) the
  exclusion degenerates, preserving the bootstrap super-admin path.
- Elevation-INDEPENDENT — the caller wires Principal.Elevated around
  the result. Keeps this function a pure cascade query, testable
  without context plumbing.

Property tests pin: super-admin matches at depth; subtree admin
matches inside scope, blocked outside; excludeLeaf hides leaf admins
(self-elevation prevention); excludeLeaf at root falls back to root;
empty email refused; role references in admins resolve through the
chain; role defined at leaf is invisible above under excludeLeaf.

Old IsAdmin / IsSubtreeAdmin / CanEditZddc stay in place during the
migration — next commits move callers across, last commit removes
them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:14:44 -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
03d008ff0a feat(browse): keyboard navigation in the file tree
Document-level keydown handler covers the W3C tree-view pattern so
users can drive the browse pane without the mouse:

  ↓ / ↑           — move selection (auto-previews files as the cursor
                    lands so the right pane keeps up)
  →               — expand collapsed folder; jump to first child if
                    already expanded; no-op on leaves
  ←               — collapse expanded folder; otherwise jump to parent
  Enter / Space   — preview file / toggle folder
  Home / End      — first / last visible row

Bails out cleanly when focus is in an input/textarea/contenteditable
or when a modal / context menu is open, so it doesn't fight existing
filter typing, YAML editor, or the right-click menu's own keys. Any
modifier (Ctrl/Cmd/Alt) lets the browser shortcut through unchanged.

Selection updates scroll the now-current row into view via
scrollIntoView({block:'nearest'}). Tree module gains a visibleIds
export so events.js can walk the same filtered+expanded order the
renderer uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:21:47 -05:00
4497ebdf99 feat(browse): extension chip under tree icon + archive refs in hovercard
Two small surface upgrades on file rows:

- Tree icon column now stacks the Lucide glyph on top of a small
  uppercase extension chip (PDF, DOCX, YAML, etc.). File type reads
  at a glance without expanding the row. Folders and zips skip the
  chip — their glyph already carries enough.
- Hovercard on a ZDDC-parseable file gains two clickable references
  in the .archive section:
    Latest         → /<project>/.archive/<tracking>.html
    This revision  → /<project>/.archive/<tracking>_<rev>.html
  Both forms are dispatcher-canonicalised to project-root, so the
  link works from any depth. Folders that parse (transmittal folders)
  get just the Latest link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:18:01 -05:00
c2423f8873 feat(browse): make virtual tree rows visually distinct
Folders the cascade declares but disk doesn't carry (working/,
staging/, reviewing/, mdl/, the canonical folders before they're
materialised) previously got just opacity:0.65 + an "(empty)" hint —
easy to miss, especially next to dimmed-but-real items.

Now they read as placeholders at a glance:

- Dashed left rail (2px, accent-muted) inside the row gutter.
- Italic label in muted text color.
- Lucide icon switches to outline-only (fill:none + stroke:currentColor)
  so virtual folders look sketched, not filled.
- "(empty)" hint italic + accent-muted to match the rail.
- Selected virtual row keeps the rail but switches it to the
  selection accent so "selected + placeholder" reads as both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:40:41 -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
94b2e29448 feat(browse): SPA overhaul — context menu, YAML editor, icons, hovercard, deep links, autofilter
Major upgrade to the browse tool's UX, plus a few shared modules other
tools can adopt.

User-facing:
- Right-click context menu on tree rows AND empty pane space. Traditional
  file-manager grouping (Open / Download / New / Rename-Delete / Copy /
  Tree ops / View). Items stay visible but disabled when not applicable
  so muscle memory carries. Generic shared/context-menu.js framework
  supports normal items, toggles, submenus, separators, danger styling.
- YAML editor for .yaml / .yml / .zddc files (CodeMirror 5 vendored at
  shared/vendor/codemirror-yaml.min.*). js-yaml lint on every change
  for parse errors. For .zddc cascade files, an additional schema-aware
  lint pass flags unknown keys, bad enum values, and wrong types.
- Per-row drag-drop upload using webkitGetAsEntry (folder uploads work
  recursively). Per-row drop indicator; doc-level overlay still fires
  for blank-space drops at drop_target scopes.
- New folder / New markdown file context-menu items (server mode).
  Rename + Delete with native confirm() dialog. File-API helpers
  removeNode / renameNode use the existing PUT/POST/DELETE endpoints.
- Hover info card with the row's full metadata (ZDDC fields + filesystem
  info + path/URL). Interactive — mouse into it, drag-select text,
  Ctrl/Cmd-C or right-click → Copy. 200ms grace before dismiss.
- Autofilter input at the top of the tree pane. Same grammar as
  archive's column filters (zddc.filter.parse / matches). Filters
  files; folders without matches collapse out. Non-matching folders
  force-open visually when descendants match, without mutating the
  user's actual expand state.
- Two-line ZDDC label: title-first, tracking/rev/status as monospace
  meta below. Icon column anchors to the title line. Chevron is a
  Lucide outline `chevron-right` SVG, rotated 90° on `.expanded`.
- File-type Lucide icon sprite (shared/icons.js — 16 outline glyphs,
  ~5 KB). PDF / Word / Spreadsheet / Slides / Image / Video / Audio /
  CAD / Web / Config / Code / Archive get distinct icons; folders
  tinted with --primary.
- Header wraps gracefully at narrow viewports (shared/base.css
  flex-wrap + title min-width:0 ellipsis). Body becomes flex column
  in browse so a wrapping header doesn't break #appMain height.
- Markdown editor opens in WYSIWYG mode by default. YAML front-matter
  + TOC sidebar reworked: flexbox layout (single visible resizer
  between FM and TOC), both bodies overflow:auto for X+Y scrollbars.
- `?file=<path>` deep links open browse pre-positioned at a specific
  file. Multi-segment paths walk into subdirectories on the way.
  Auto-flips Show hidden when a segment is dot/underscore-prefixed.
- Refresh + show-hidden toggle preserve expansion / selection /
  preview pinning. Path-keyed snapshot survives a re-fetched listing.
- "Add Local Directory" → "Use Local Directory" across the four tools
  that have it (browse, archive, classifier, +transmittal comment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:12:42 -05:00
e5ba2b6168 fix(tables): handle bare-directory URLs served as default_tool
Visiting `/Project-1/archive/PartyA/mdl` (no trailing slash) errored with
`Unrecognized table URL` because tableNameFromUrl only matched
`…/<rowsdir>/table.html`. The cascade declares `default_tool: tables` at
`archive/<party>/mdl`, so the server serves the tables HTML at the bare
directory URL — a shape the client didn't recognize.

Two coordinated fixes:

- shared/zddc-source.js `pathToDir`: was over-eagerly stripping the last
  segment when the URL didn't end in `/`. Now checks whether the last
  segment contains a dot — file URLs strip to parent (original behavior
  preserved), bare-directory URLs append the missing slash. Only call
  site is detectServerRoot, so blast radius is contained.
- tables/js/context.js `tableNameFromUrl` + `rowEditUrl`: accept both
  legacy `…/<rowsdir>/table.html` and the new bare-directory shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:12:06 -05:00
159 changed files with 14851 additions and 5443 deletions

View file

@ -165,6 +165,27 @@ All JS is vanilla, no bundlers. Files are IIFEs, registered on `window.app.modul
**Exception:** archive uses plain globals (`APP_STATE`, top-level functions) — not the IIFE/modules pattern.
**State values used inside event handlers must be read fresh from the source of truth, never captured at mount.** No bundler, no reactivity layer — closures don't get refreshed when the underlying state mutates. Cache the *node*, re-read the *bit* at click time:
```javascript
// Wrong — `writable` is whatever canSave returned at mount, even if
// the tree node's bit later flips to true (e.g. admin toggle reload
// re-fetched the listing).
var writable = canSave(node);
saveBtn.addEventListener('click', function () {
if (!writable) return; // STALE
});
// Right — re-read at click time.
saveBtn.addEventListener('click', function () {
if (!canSave(node)) return; // current
});
```
It's fine to use mount-time captures for *initial UI shape* (read-only banner, CodeMirror `readOnly:'nocursor'`, etc.) — those decisions are correct at the moment they're applied. The rule is specifically about gating logic in handlers that fire *after* mount.
This pattern bit twice in the markdown + YAML editors before we caught it: the typo `writable` (undefined) vs `writableMode` (captured) made every save click a no-op. Re-reading the source of truth would have surfaced the bug at click time instead of silently disabling save.
## ZDDC filename parsers
All parsing/formatting goes through `shared/zddc.js`, exposed as `window.zddc`. Tools call it directly — no per-tool wrappers.
@ -324,17 +345,30 @@ The markdown editor lives at `browse/js/preview-markdown.js` and is mounted as t
## Server-side document conversion (`zddc/internal/convert`)
zddc-server can convert `.md` → DOCX/HTML/PDF on demand at `GET /<path>/foo.md?convert=docx|html|pdf`. Implementation:
zddc-server can convert `.md` → DOCX/HTML/PDF on demand at `GET /<path>/foo.md?convert=docx|html|pdf`.
- **Two upstream images, pulled on first use.** No custom image build. Operator just needs `podman` or `docker` installed; the runner passes `--pull=missing` so the first request pulls each image and subsequent requests use the local cache.
- `docker.io/pandoc/latex:latest` — pandoc's official image, entrypoint `pandoc`. Used for MD → DOCX and MD → HTML. Override via `--convert-pandoc-image=` / `ZDDC_CONVERT_PANDOC_IMAGE` (e.g. switch to `docker.io/pandoc/core:latest` for a ~90% size reduction).
- `docker.io/zenika/alpine-chrome:latest` — Zenika's Alpine + Chromium image, entrypoint `chromium-browser`. Used for HTML → PDF (the PDF flow is two-stage: pandoc image emits HTML using viewer-template.html, chromium image prints it). Override via `--convert-chromium-image=` / `ZDDC_CONVERT_CHROMIUM_IMAGE`.
- Engine is podman preferred, docker fallback (`--convert-engine=` / `ZDDC_CONVERT_ENGINE` to override). No host pandoc or chromium needed.
- Each conversion runs in a throw-away container with `--rm --pull=missing --network=none --read-only --tmpfs=/tmp:size=128m,exec --memory --cpus --pids-limit --cap-drop=ALL --security-opt=no-new-privileges --env=HOME=/tmp`. Resource caps via `--convert-mem-mib` (default 512), `--convert-cpus` (default "2"), `--convert-pids` (default 100), `--convert-timeout` (default 30s). `--user` is intentionally not set so each image uses its default (root for pandoc/latex, uid 1000 for alpine-chrome) — the other flags already provide strong isolation and overriding the user would break alpine-chrome's user-data-dir layout.
- I/O via bind mount + stdin/stdout. Pandoc reads markdown from stdin, writes to stdout. The viewer template is bind-mounted read-only at `/tpl`. Chromium reads HTML from a read-write bind mount at `/pdf` and writes the PDF to the same mount; the host reads it back.
**Architecture.** zddc-server's Go code does the bare minimum: it `exec.Command("pandoc", args...)` or `exec.Command("chromium-browser", args...)`. **The sandbox + resource caps live in the IMAGE**, not in Go. In the production runtime image (`zddc/runtime.Containerfile`), `/usr/local/bin/pandoc` and `/usr/local/bin/chromium-browser` are symlinks to `zddc-sandbox-exec` — a shell wrapper that:
1. Creates a transient cgroup v2 (memory + pids cap from `ZDDC_CONV_MEM_MAX` / `ZDDC_CONV_PIDS_MAX` env), moves itself in.
2. Wraps the real binary at `/usr/bin/<name>` in a bubblewrap sandbox (`--unshare-all --unshare-user-try --die-with-parent --ro-bind /usr /usr ... --proc /proc --dev /dev --tmpfs /tmp --clearenv`).
3. exec's `/usr/bin/<name>` with the original argv.
Why this shape: swapping isolation strategies (firejail, systemd-nspawn, podman-run, raw exec for dev) is purely an image concern. The Go code never changed. A separate `zddc-cgroup-init` script runs at container start to delegate cgroup v2 `subtree_control` (the "no internal processes" constraint), then exec's zddc-server. Both scripts live in `zddc/runtime/`.
**Outer-container privileges.** Nested bwrap needs the outer container to permit user + mount namespace creation. Pod Security Standards defaults block this. The helm chart sets `securityContext: capabilities.add: [SYS_ADMIN]`, `seccompProfile.type: Unconfined`, `appArmorProfile.type: Unconfined`. Trade-off: a zddc-server RCE has near-root power within the container's namespace, but the bind-mount layout (overlay fs, no host /home or /usr visible) still bounds the blast radius. The per-conversion bwrap sandbox is the real isolation boundary between zddc-server and untrusted pandoc/chromium.
**Config knobs** (all in `cmd/zddc-server`):
- `--convert-pandoc-binary` (default `pandoc`) / `--convert-chromium-binary` (default `chromium-browser`; `chromium` on debian)
- `--convert-scratch-dir` (default `$TMPDIR`) — host scratch root; the wrapper bind-mounts the per-call subdir
- `--convert-mem-mib` (default 1024) → wrapper's `memory.max`
- `--convert-pids` (default 256) → wrapper's `pids.max`
- `--convert-timeout` (default 60s) → enforced in Go via `context.WithTimeout`
**Other plumbing.**
- I/O via stdin/stdout + scratch dir. Pandoc reads markdown from stdin, writes to stdout. Templates + intermediate HTML + output PDF live in a per-call subdir under the scratch root; the dir's host path is passed to the child via `ZDDC_SCRATCH` so the wrapper bind-mounts it into the sandbox at the same path (no path translation).
- Output cached at `<dir>/.converted/<base>.<ext>` (hidden by the `.` prefix). mtime synced to source so the fast path is a stat-and-serve with no exec. PUT/DELETE/MOVE on the source `.md` purges the sidecars.
- Per-project template variables (client/project/contractor/project_number) come from `.zddc` `convert:` cascade keys. Title/tracking_number/revision/status are derived from the filename via `zddc.ParseFilename`.
- If neither podman nor docker is present, the endpoint serves 503 with a Retry-After. The rest of the server keeps working.
- If pandoc/chromium aren't on PATH (operator running zddc-server outside the runtime image), the endpoint serves 503 with a Retry-After. The rest of the server keeps working. Operators who run zddc-server with raw pandoc/chromium (no wrapper) get a working but unsandboxed conversion endpoint — useful for dev iteration.
## Form-data system (`form/` + zddc-server form handler)
@ -569,6 +603,18 @@ The tokens directory inherits the existing `.zddc.d/` exclusion: dot-prefix segm
Implementation: `zddc/internal/auth/` (storage), `zddc/internal/handler/tokenhandler.go` (HTTP layer), middleware extension in `zddc/internal/handler/middleware.go`.
### Admin elevation (sudo-style)
Admins are treated as normal users by default; admin escape hatches (WORM bypass, auto-own takeover, `.zddc` edit authority, profile admin scaffolds) require an explicit per-request opt-in. The toggle lives in every tool's header (left of the theme button) and writes a `zddc-elevate=1` cookie (Max-Age=1800, SameSite=Lax) — 30-minute sudo window before it auto-expires.
Server-side the model is `zddc.Principal{Email, Elevated}`. `ACLMiddleware` builds it once per request and stashes it in context; `IsAdmin` / `IsSubtreeAdmin` / `CanEditZddc` take a `Principal` parameter rather than a bare email. That signature change is the enforcement mechanism — the compiler tells you when an admin call site doesn't thread elevation, so a "forgot to gate this" mistake doesn't compile. `PrincipalFromContext(r)` is the one-call-per-site bundling helper.
Bearer tokens are **implicitly elevated** — CLI clients and the mirror process can't toggle a cookie, and their authority is the bearer's full grant by design. Browser sessions elevate only when the user opts in.
`/.profile/access` exposes `can_elevate` (elevation-independent "does this email have any admin grant anywhere?") so the header toggle can render itself for an un-elevated admin who hasn't opted in yet. The access log captures `elevated=<true|false>` per request for forensics.
Implementation: `zddc/internal/zddc/admin.go` (Principal struct + gated functions), `zddc/internal/handler/middleware.go` (cookie/bearer → ElevatedKey context value), `shared/elevation.{js,css}` (header toggle UI, concat'd into every tool's bundle).
### Release tagging
zddc-server has no separate release script. The top-level `./build alpha|beta|release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the lockstep symlink chain (one set of symlinks per platform), regenerates the per-version + per-channel stub pages, refreshes the index, and (on stable) tags `zddc-server-v<X.Y.Z>` alongside the eight HTML-tool tags.

View file

@ -403,7 +403,7 @@ Files at the root level are ignored. The grouping folder list and transmittal fo
**Dependencies:** Toast UI Editor v3.2.2 (vendored at `shared/vendor/toastui-editor-all.min.js`, concatenated into `browse/dist/browse.html` at build time). No runtime CDN, no Tailwind.
**Server-mode features:** When the file handle is an `HttpFileHandle` (so `node.url` is set and `state.source === 'server'`), three Download buttons appear in the file header — DOCX/HTML/PDF — fetching `?convert=<fmt>` via `window.zddc.source.downloadConverted()`. Clicks auto-save first if the buffer is dirty so converted bytes reflect what's on screen. See `zddc/internal/convert` for the server-side engine.
**Server-mode features:** When the file handle is an `HttpFileHandle` (so `node.url` is set and `state.source === 'server'`), three Download buttons appear in the file header — DOCX/HTML/PDF — fetching `?convert=<fmt>` via `window.zddc.source.downloadConverted()`. Clicks auto-save first if the buffer is dirty so converted bytes reflect what's on screen. The server-side engine is in `zddc/internal/convert`: zddc-server `exec.Command`s `pandoc` and `chromium-browser` directly, and the runtime image's wrapper at `/usr/local/bin/<name>` (see `zddc/runtime.Containerfile` + `zddc/runtime/zddc-sandbox-exec`) handles the per-call cgroup v2 + bubblewrap sandbox between that exec and the real binary at `/usr/bin/<name>`. Isolation strategy lives entirely in the image; swap the wrapper for firejail / nspawn / podman-run and Go doesn't change.
---
@ -498,7 +498,7 @@ none of them is load-bearing alone.
|---|---|---|
| Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer <token>` validated against `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` (CLI / scripted callers); or `X-Auth-Request-Email` injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required |
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in `defaults.zddc.yaml` bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins under `--cascade-mode=delegated`, or with absolute ancestor denies under `--cascade-mode=strict` (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data |
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in `defaults.zddc.yaml` bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego (the bundled `access_federal.rego` is the parent-deny-is-absolute / NIST AC-6 variant) while keeping the same `.zddc` files as input data |
| Canonical-folder behaviour | Codify the bilateral exchange-record archetype | All driven by `.zddc` keys (baked into `defaults.zddc.yaml`): `auto_own:` / `auto_own_fenced:` — mkdir here writes a creator-owned `.zddc` (`<email>: rwcda`; fenced adds `acl.inherit:false`); `worm: [principal…]` — write-once-read-many (`w`/`d`/`a` stripped for everyone non-admin, `c` survives only for the listed principals; admins exempt); `virtual:` — never materialise on disk; `drop_target:` — browse shows a drag-drop upload overlay. The defaults put `auto_own` on `working`/`staging`/`archive-party`/`incoming` and `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical convention is unchanged — but an operator can reshape it (rename `received`/`issued`, mark any path WORM, …) without a code change. `zddc/internal/zddc/lookups.go`, `worm.go`, `roles.go`; `defaults.zddc.yaml` |
| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above |
| URL canonicalization | Resolve URL paths to on-disk casing before any layer below sees them | `zddc/internal/fs/resolve.go ResolveCanonical` — case-insensitive lookup with lowercase-wins tiebreak when sibling case variants exist on disk. File and folder names preserve case on disk; the canonicalization is purely URL→FS-name mapping. Virtual prefixes (`.archive`, `.profile`, `.tokens`) flow through verbatim |
@ -654,7 +654,7 @@ whether to deploy the system should know which column they're in.
| Cryptography | Go stdlib defaults | FIPS 140-3 validated module (microsoft/go or RHEL FIPS) |
| TLS | Go stdlib defaults | Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS |
| Access model | Per-verb (`r`/`w`/`c`/`d`/`a`) with first-class roles and an admin escape hatch — closes NIST AC-3(7) | (closed by default; external Rego still available for org-specific policy via `ZDDC_OPA_URL`) |
| Subtree authority | Operator-toggled cascade mode: `delegated` (default — leaf grants override ancestor denies) or `strict` (`--cascade-mode=strict` — ancestor explicit-denies are absolute, NIST AC-6) | (closed; `strict` is the federal posture) |
| Subtree authority | In-process decider: leaf grants override ancestor denies (delegation primitive). Federal posture: deploy OPA with `access_federal.rego` for ancestor-deny-absolute / NIST AC-6 | (closed; federal posture is the OPA path) |
| Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive |
| Information disclosure | Anonymous reaches `/` and `/.profile` (project picker, public-projects names) | All endpoints behind authenticated proxy; no anonymous discovery |
| Apps URL fetches | Fetch-once-cached, no integrity check | SHA-256 pin + signature verification |
@ -675,12 +675,9 @@ Five permission verbs gate every read and write:
| `d` | delete a file |
| `a` | modify the ACL of this subtree (write `.zddc`) |
`.zddc` files express grants under `acl.permissions: { principal → verb-set }`. A principal containing `@` is an email pattern matched by `MatchesPattern` (existing glob); a bare name is a role looked up against `roles:` definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny. Legacy `acl.allow` / `acl.deny` lists fold into `permissions` at parse time (`allow` → `rwcd`, `deny``""`), so existing deployments behave identically.
`.zddc` files express grants under `acl.permissions: { principal → verb-set }`. A principal containing `@` is an email pattern matched by `MatchesPattern` (existing glob); a bare name is a role looked up against `roles:` definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny.
Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. Operators select the precedence model for ancestor denies via `--cascade-mode`:
- `delegated` (default) — historical commercial behavior; a leaf allow overrides an ancestor explicit-deny.
- `strict` — NIST AC-6 posture; an ancestor explicit-deny is absolute and cannot be overridden by any leaf grant.
Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. A leaf allow overrides an ancestor explicit-deny — that's the load-bearing delegation primitive that lets a subtree owner grant access without root-admin involvement. Operators who need the opposite rule (ancestor-deny-absolute, NIST AC-6) deploy OPA with the bundled `access_federal.rego`.
The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypass: root admins (`IsAdmin`) and subtree admins (`IsSubtreeAdmin`) get unconditional `rwcda` and skip both the cascade and the WORM mask.

View file

@ -82,5 +82,6 @@ No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSI
- **`</` in JS string/template literals breaks inline `<script>`** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining.
- **All ZDDC parsing/formatting/hashing goes through `window.zddc`** (from `shared/zddc.js` + `shared/hash.js` + `shared/zddc-filter.js`). API: `parseFilename`, `parseFolder`, `parseRevision`, `formatFilename`, `formatFolder`, `compareRevisions`, `isValidStatus`, `splitExtension`, `joinExtension`, `crypto.{sha256Hex, sha256String, sha256File, bytesToHex}`, `filter.{parse, matches}`. File objects across tools use `trackingNumber` (string) and `extension` (string, **no leading dot** — use `zddc.joinExtension(name, ext)` to build a filename). Add edge cases to `tests/zddc.spec.js`, not per-tool tests.
- **Two globals only**: `window.app` (per-tool app state + modules) and `window.zddc` (shared library). No others — anything that crosses tool boundaries goes through one of these.
- **Admin elevation is sudo-style.** Admins behave as normal users by default; opting into admin powers is per-request and gated by the `zddc-elevate=1` cookie (Max-Age=1800, set by the header toggle in every tool). Server-side: `zddc.Principal{Email, Elevated}` is built once per request by `handler.ACLMiddleware` and threaded into `IsAdmin`/`IsSubtreeAdmin`/`CanEditZddc` — the compiler enforces the gate at every admin call site (no easy "forgot to check elevation" mistake). Bearer-token requests are implicitly elevated since CLI clients can't toggle a cookie; browser sessions elevate only when the user clicks the header checkbox. `/.profile/access` exposes `can_elevate` (elevation-independent "does this email have admin authority anywhere?") so the header toggle can decide whether to render itself for an un-elevated admin. The access-log captures the `elevated` flag per request for forensics.
- **Worktrees live at `~/src/zddc-<branch>`.** Check `git worktree list` before starting a feature branch; never `git checkout`/`switch` inside a worktree another agent might be using.
- **Build scripts are POSIX `sh` with `set -eu`**, not bash. `concat_files` takes positional args only.

4
archive/build.sh Normal file → Executable file
View file

@ -22,7 +22,7 @@ concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/elevation.css" \
"../shared/logo.css" \
"css/base.css" \
"css/layout.css" \
@ -46,7 +46,6 @@ concat_files \
"../shared/zip-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/preview-lib.js" \
"js/init.js" \
@ -64,6 +63,7 @@ concat_files \
"js/events.js" \
"js/app.js" \
"../shared/help.js" \
"../shared/elevation.js" \
> "$js_raw"
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents

View file

@ -69,7 +69,7 @@
// Apply UI differences based on source mode
function applySourceModeUI() {
// "Add Local Directory" button is always visible in both modes —
// "Use Local Directory" button is always visible in both modes —
// in HTTP mode the user can augment the online archive with local directories.
}

View file

@ -32,11 +32,17 @@
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
</div>
</header>
@ -240,7 +246,7 @@
<div id="noDirectoryMessage" class="empty-state empty-state--overlay">
<div class="empty-state__inner empty-state__inner--centered">
<h2>Welcome to ZDDC Archive</h2>
<p>Click <strong>Add Local Directory</strong> to select an archive folder to browse.</p>
<p>Click <strong>Use Local Directory</strong> to select an archive folder to browse.</p>
<p>This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.</p>
<p><strong>How to navigate:</strong></p>
<ul class="welcome-list">
@ -285,7 +291,7 @@
<h3>Getting Started</h3>
<ol>
<li>When opened from a web server, the archive loads automatically from that server.</li>
<li>Click <strong>Add Local Directory</strong> to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.</li>
<li>Click <strong>Use Local Directory</strong> to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.</li>
<li>The browser scans for grouping folders and transmittal folders automatically.</li>
<li>Select folders in the left panel to see their files in the main table.</li>
</ol>

View file

@ -24,11 +24,14 @@ concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"../shared/vendor/toastui-editor.min.css" \
"../shared/vendor/codemirror-yaml.min.css" \
"../shared/context-menu.css" \
"../shared/elevation.css" \
"css/base.css" \
"css/tree.css" \
"css/preview-yaml.css" \
> "$css_temp"
# JS files: shared canonical helpers, then browse modules.
@ -39,25 +42,35 @@ concat_files \
concat_files \
"../shared/vendor/jszip.min.js" \
"../shared/vendor/utif.min.js" \
"../shared/vendor/js-yaml.min.js" \
"../shared/vendor/codemirror-yaml.min.js" \
"../shared/vendor/toastui-editor-all.min.js" \
"../shared/zddc.js" \
"../shared/zddc-filter.js" \
"../shared/zip-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/help.js" \
"../shared/preview-lib.js" \
"../shared/context-menu.js" \
"../shared/elevation.js" \
"../shared/icons.js" \
"../shared/zddc-source.js" \
"js/init.js" \
"js/loader.js" \
"js/tree.js" \
"js/preview.js" \
"js/preview-markdown.js" \
"js/preview-yaml.js" \
"js/hovercard.js" \
"js/grid.js" \
"js/upload.js" \
"js/download.js" \
"js/plan-review.js" \
"js/accept-transmittal.js" \
"js/stage.js" \
"js/create-transmittal.js" \
"js/events.js" \
"js/app.js" \
> "$js_raw"

View file

@ -40,3 +40,20 @@ body {
.status-bar--error { color: #b00020; }
.status-bar--info { color: var(--primary); }
/* Read-only banner for the YAML editor surfaced by preview-yaml.js
when the listing's `writable` bit was false. CodeMirror's readOnly
mode has no built-in visual signal beyond the disabled caret, so a
banner here is the explicit cue. The markdown editor doesn't need
one because its read-only mount uses Toast UI's Viewer (no edit
toolbar at all). */
.yaml-readonly-banner {
background: rgba(220, 53, 69, 0.10);
color: var(--text);
border-bottom: 1px solid rgba(220, 53, 69, 0.35);
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.4rem;
}

110
browse/css/preview-yaml.css Normal file
View file

@ -0,0 +1,110 @@
/* preview-yaml.css YAML editor pane styling. Mirrors the
.md-shell info-header geometry; everything below is a CodeMirror 5
host with dark-mode overrides so the editor blends into the theme
instead of fighting it. */
.yaml-shell {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--bg);
}
.yaml-shell__editor {
min-height: 0;
overflow: hidden;
position: relative;
}
/* Schema-label badge extends .md-shell__source so it sits next to
"local"/"server"/"read-only (zip)" with the same chip styling. The
primary-colored variant distinguishes ".zddc schema" from the
plain "YAML" label. */
.yaml-shell__schema {
font-style: normal;
}
.yaml-shell__schema:not(:empty) {
border-color: var(--primary);
color: var(--primary);
}
/* CodeMirror has to fill the grid cell. The vendored CSS sets
`height: 300px` by default we override to 100% so it grows with
the preview pane. */
.yaml-shell__editor .CodeMirror {
height: 100%;
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.45;
background: var(--bg);
color: var(--text);
}
.yaml-shell__editor .CodeMirror-gutters {
background: var(--bg-secondary);
border-right: 1px solid var(--border);
}
.yaml-shell__editor .CodeMirror-linenumber {
color: var(--text-muted);
}
.yaml-shell__editor .CodeMirror-cursor {
border-left-color: var(--text);
}
.yaml-shell__editor .CodeMirror-selected {
background: var(--bg-selected);
}
.yaml-shell__editor .CodeMirror-focused .CodeMirror-selected {
background: var(--primary-light);
}
/* YAML token tints. CM5 emits semantic class names from the yaml
mode; map them onto our palette so themes flip with the OS / data
attribute. */
.yaml-shell__editor .cm-keyword,
.yaml-shell__editor .cm-atom { color: var(--primary); font-weight: 600; }
.yaml-shell__editor .cm-string { color: #2e8b57; }
.yaml-shell__editor .cm-comment { color: var(--text-muted); font-style: italic; }
.yaml-shell__editor .cm-number { color: #b06000; }
.yaml-shell__editor .cm-meta { color: #6f42c1; }
@media (prefers-color-scheme: dark) {
html:not([data-theme="light"]) .yaml-shell__editor .cm-string { color: #98c379; }
html:not([data-theme="light"]) .yaml-shell__editor .cm-number { color: #e5c07b; }
html:not([data-theme="light"]) .yaml-shell__editor .cm-meta { color: #c678dd; }
}
[data-theme="dark"] .yaml-shell__editor .cm-string { color: #98c379; }
[data-theme="dark"] .yaml-shell__editor .cm-number { color: #e5c07b; }
[data-theme="dark"] .yaml-shell__editor .cm-meta { color: #c678dd; }
/* Lint markers: keep CM's defaults for the gutter dots but make the
inline underline play nicely with our background. Errors stay red,
warnings amber. */
.yaml-shell__editor .CodeMirror-lint-mark-error {
background-image: none;
border-bottom: 2px wavy var(--danger);
}
.yaml-shell__editor .CodeMirror-lint-mark-warning {
background-image: none;
border-bottom: 2px wavy var(--warning);
}
/* Tooltip popping out of a lint marker uses the shared menu shadow
so it doesn't look like a separate component. */
.CodeMirror-lint-tooltip {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18),
0 2px 6px rgba(0, 0, 0, 0.10);
font-family: var(--font);
font-size: 0.82rem;
padding: 0.3rem 0.55rem;
max-width: 32rem;
}

View file

@ -4,15 +4,33 @@ html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family: var(--font);
color: var(--text);
background-color: var(--bg);
}
/* Body is a flex column so the header (which may wrap to a second
row at narrow viewports), #appMain, and the status bar each get
their natural height no more fixed-pixel calc() that breaks
when the header reflows. Horizontal overflow scrolls on the body
as a final fallback when content can't shrink any further. */
body {
display: flex;
flex-direction: column;
height: 100vh;
overflow-x: auto;
overflow-y: hidden;
/* Hard floor for the body. Below this, the html-level scrollbar
picks up and the user can pan horizontally rather than seeing
the right edge clipped. */
min-width: 320px;
}
#appMain {
position: relative;
height: calc(100vh - 2.65rem); /* clear .app-header */
flex: 1 1 auto;
min-height: 0;
height: auto; /* override the old calc(100vh - 2.65rem) */
display: flex;
flex-direction: column;
overflow: hidden;
@ -109,12 +127,6 @@ html, body {
vertical-align: -0.15em;
}
.toolbar__count {
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
}
/* ── Two-pane browse view ────────────────────────────────────────────────── */
.browse-view {
@ -139,6 +151,42 @@ html, body {
flex-shrink: 0;
}
.tree-pane__toolbar {
padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
/* Single-input autofilter same grammar as the archive app's column
filters (terms, quotes, !negation, multi-word AND). type=search so
the browser ships the native clear-X for free; the .filter-active
class amber-highlights the input while a query is set, matching
the archive `.column-filter.filter-active` cue. */
.tree-filter {
width: 100%;
box-sizing: border-box;
padding: 0.3rem 0.5rem;
font-family: var(--font);
font-size: 0.85rem;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
outline: none;
transition: border-color 0.12s, background 0.12s;
}
.tree-filter:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--primary-light);
}
.tree-filter.filter-active {
background: rgba(234, 179, 8, 0.18);
border-color: rgba(234, 179, 8, 0.7);
}
.tree-pane__body {
flex: 1;
overflow: auto;
@ -250,9 +298,12 @@ html, body {
.tree-row {
display: flex;
align-items: center;
/* Top-aligned so the chevron + icon anchor to the title line on
two-line ZDDC rows. Single-line rows are unaffected because the
icon, chevron, and label all share a top edge. */
align-items: flex-start;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
padding: 0.2rem 0.5rem;
cursor: pointer;
user-select: none;
border-radius: 0;
@ -268,37 +319,91 @@ html, body {
color: var(--text);
}
/* Per-row drop target highlight: applied while a file/folder drag is
hovering this row. The dashed outline reads as "drop here" without
shifting layout. */
.tree-row.is-droptarget {
background: var(--primary-light);
outline: 2px dashed var(--primary);
outline-offset: -2px;
}
.tree-row.is-selected .tree-name__label {
color: var(--text);
}
.tree-name__chevron {
display: inline-block;
/* Fixed-width slot so leaf rows (empty chevron) still align with
expandable rows. The SVG inside is sized via the rule below.
Top-anchored to the title-line baseline by the row's flex-start
alignment + this small top offset. */
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
text-align: center;
color: var(--text-muted);
height: 1.2em;
flex-shrink: 0;
font-family: monospace;
font-size: 0.65rem;
color: var(--text-muted);
}
.tree-row[data-isdir="true"] .tree-name__chevron::before,
.tree-row[data-iszip="true"] .tree-name__chevron::before {
content: "▸";
.tree-name__chevron svg {
width: 0.85em;
height: 0.85em;
transition: transform 0.12s ease;
}
.tree-row[data-isdir="true"].expanded .tree-name__chevron::before,
.tree-row[data-iszip="true"].expanded .tree-name__chevron::before {
content: "▾";
}
.tree-name__chevron--leaf::before {
content: "";
/* Expanded state rotate the same chevron 90° rather than swapping
to a second glyph. Smooth, single-sprite, and consistent with the
way most modern file trees indicate expand state. */
.tree-row.expanded .tree-name__chevron svg {
transform: rotate(90deg);
}
.tree-name__icon {
flex-shrink: 0;
font-size: 0.95rem;
/* Stacked column glyph on top, extension chip below for files.
Wider min-width than the 1em glyph itself so common extensions
(pdf/docx/xlsx/json) don't push the label sideways. Height
grows with content; flex-start anchors to the title-line. */
min-width: 2.2em;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
color: var(--text-muted);
gap: 1px;
}
.tree-name__icon svg {
width: 1em;
height: 1em;
display: block;
}
.tree-name__ext {
font-size: 0.58rem;
line-height: 1;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Folder rows get the primary accent so directories stand out from
files at a glance same convention as macOS Finder / GNOME Files. */
.tree-row[data-isdir="true"] .tree-name__icon,
.tree-row[data-iszip="true"] .tree-name__icon {
color: var(--primary);
}
/* Selected rows tint icon to match the label color (the bg-selected
token already differentiates the row background). */
.tree-row.is-selected .tree-name__icon {
color: var(--text);
}
.tree-name__label {
@ -306,6 +411,48 @@ html, body {
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
min-width: 0;
}
/* Two-line ZDDC variant. Top line is monospace + small + muted so the
trackingNumber / revision / status fields line up vertically across
adjacent rows (every field has a fixed width by convention). Bottom
line is the human-readable title at normal weight. */
.tree-name__label--zddc {
display: flex;
flex-direction: column;
line-height: 1.15;
/* Tight gap between meta and title; tweak by 1-2 px if the rows
feel crowded on dense lists. */
gap: 0.05rem;
}
.tree-name__meta {
font-family: var(--font-mono);
font-size: 0.7rem;
/* Explicit weight: the folder-row rule below bolds .tree-name__label,
which would otherwise inherit through to the meta span. We want
the meta to stay light + muted on every row. */
font-weight: 400;
color: var(--text-muted);
/* Belt-and-braces: monospace already gives column-alignment, but
tabular-nums hardens it on the rare proportional fallback. */
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-name__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.tree-row.is-selected .tree-name__title {
color: var(--text);
}
.tree-row[data-isdir="true"] .tree-name__label,
@ -364,19 +511,52 @@ html, body {
word-break: break-all;
}
/* Virtual rows: synthesized client-side for folders that aren't on
disk yet (canonical project folders). Rendered muted so the user
reads them as "available but empty" rather than ordinary entries.
Hover/select states still apply; the hint sits to the right of the
label. */
.tree-row--virtual .tree-name__icon,
.tree-row--virtual .tree-name__label {
opacity: 0.65;
/* Virtual rows: synthesized for folders/files declared by the
cascade but absent from disk. The visual language reads as
"expected, not yet materialized" italic label, muted accent
color, dashed left rail, and an outlined icon. Hover/select
chrome still applies on top; the dashed rail sits inside the row
so it doesn't fight padding-left indentation. */
.tree-row--virtual {
box-shadow: inset 2px 0 0 0 transparent;
position: relative;
}
.tree-row--virtual::before {
content: '';
position: absolute;
top: 4px;
bottom: 4px;
left: 2px;
border-left: 2px dashed var(--accent-muted, #8aa4cc);
pointer-events: none;
}
.tree-row--virtual .tree-name__label {
font-style: italic;
color: var(--text-muted, #6b7280);
}
.tree-row--virtual .tree-name__icon {
/* Hollow out the filled Lucide glyph: reduce fill opacity so
the icon reads as an outline-only sketch the conventional
"placeholder, not actual" cue across UI systems. */
opacity: 0.5;
}
.tree-row--virtual .tree-name__icon svg {
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.tree-row--virtual.is-selected::before {
/* Selected virtual row: rail brightens to selection accent so the
row reads as both selected and placeholder. */
border-left-color: var(--accent, #2868c8);
}
.tree-name__hint {
margin-left: 0.5rem;
font-size: 0.78rem;
color: var(--text-muted);
color: var(--accent-muted, #8aa4cc);
font-style: italic;
}
@ -427,12 +607,15 @@ html, body {
overflow: hidden;
}
/* Sidebar (col 1): two stacked sections Front matter (top, fixed
default 180 px, drag-resizable) and TOC (bottom, takes the rest). */
/* Sidebar (col 1): three stacked items Front matter (fixed height,
drag-resizable), the horizontal resizer (between FM and TOC), then
the TOC section taking the remaining height. Flexbox keeps the
resizer position unambiguous; the previous grid-overlay approach
was hard to read and prone to misplacement. */
.md-shell__sidebar {
grid-area: sidebar;
display: grid;
grid-template-rows: 180px 1fr; /* JS overrides on resize */
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
border-right: 1px solid var(--border);
@ -460,20 +643,17 @@ html, body {
outline: none;
}
/* Horizontal resizer between front-matter and TOC inside the sidebar.
Spans both rows by placement, then absolutely positioned to overlay
the grid-row boundary. */
/* Horizontal resizer a real flex item between FM and TOC. Drag
it up/down to change the front-matter pane's height; the JS
handler updates fmSection.style.height directly. */
.md-shell__fmresizer {
grid-column: 1;
grid-row: 1;
align-self: end;
justify-self: stretch;
flex: 0 0 6px;
height: 6px;
margin-bottom: -3px;
cursor: row-resize;
background: transparent;
z-index: 2;
background: var(--border);
transition: background 0.12s;
/* Subtle "grab" affordance a slightly darker bar appears on
hover so users see this is the drag handle. */
}
.md-shell__fmresizer:hover,
.md-shell__fmresizer.is-dragging,
@ -558,15 +738,30 @@ html, body {
}
.md-side {
display: grid;
grid-template-rows: auto 1fr;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.md-side--toc {
border-top: 1px solid var(--border);
/* Front-matter section: fixed (resizable) height, set inline by the
markdown plugin's mount + drag-handler. flex:0 0 auto so the
explicit height wins over the parent flex layout. */
.md-side--fm {
flex: 0 0 auto;
}
/* TOC section: takes everything that's left. min-height:0 so the
inner body's overflow:auto kicks in instead of pushing the
resizer off-screen. */
.md-side--toc {
flex: 1 1 auto;
min-height: 0;
}
.md-side__header {
/* Header is its own flex item so the body can stretch to fill. */
flex: 0 0 auto;
padding: 0.35rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
@ -576,8 +771,13 @@ html, body {
letter-spacing: 0.06em;
color: var(--text-muted);
}
.md-side__body {
overflow-y: auto;
/* Both axes the textarea uses white-space:pre so long YAML
lines need horizontal scroll, and the TOC entries below now
extend their full width so deep headings need it too. */
flex: 1 1 auto;
overflow: auto;
min-height: 0;
padding: 0.3rem 0;
font-size: 0.85rem;
@ -604,10 +804,11 @@ html, body {
cursor: pointer;
border-left: 2px solid transparent;
transition: background 0.1s, border-color 0.1s, color 0.1s;
/* Truncate long headings rather than wrap; the title attribute
carries the full text. */
overflow: hidden;
text-overflow: ellipsis;
/* Single-line items but no ellipsis long headings extend the
item's intrinsic width, and the parent .md-side__body has
overflow:auto, so they create a horizontal scrollbar instead
of getting clipped. The title attribute still carries the
full text for SR users. */
white-space: nowrap;
}
.md-toc__item:hover {
@ -670,44 +871,116 @@ html, body {
cursor: not-allowed;
}
/* ── Sort control ────────────────────────────────────────────────────────── */
.sort-control {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
}
.sort-control__label {
user-select: none;
}
.sort-control__select {
font-family: var(--font);
font-size: 0.8rem;
padding: 0.2rem 0.4rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
cursor: pointer;
}
.sort-control__select:focus {
outline: 2px solid var(--primary);
outline-offset: -1px;
}
.sort-control__checkbox {
/* Pair with the "Show hidden" label as a unified control. The
parent .sort-control already does horizontal flex + gap, so the
checkbox just needs sensible vertical alignment + a clickable
hit target. */
margin: 0;
cursor: pointer;
}
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
by the .md-shell BEM block above. */
/* ── Hover info card ────────────────────────────────────────────────────── */
/* Singleton element appended to <body> by browse/js/hovercard.js.
Replaces the native title="…" tooltip on tree rows with a rich
metadata view (ZDDC parse fields + filesystem info). */
.tree-hovercard {
position: fixed;
z-index: 9000;
max-width: 28rem;
min-width: 17rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18),
0 2px 6px rgba(0, 0, 0, 0.10);
padding: 0.5rem 0.7rem 0.45rem;
font-family: var(--font);
font-size: 0.8rem;
line-height: 1.35;
opacity: 0;
visibility: hidden;
/* pointer-events:auto so the user can mouse into the card to
select text. The hide is delayed (HIDE_DELAY_MS in hovercard.js)
so the cursor has time to traverse the gap between row and card
before the card dismisses. */
pointer-events: auto;
/* The tree rows set user-select:none explicitly allow it here
so dragging across the card builds a real selection that can be
Ctrl/Cmd-C'd or right-click-Copied via the browser's native menu. */
user-select: text;
cursor: default;
transition: opacity 0.1s ease;
}
.tree-hovercard.is-visible {
opacity: 1;
visibility: visible;
}
/* Highlight selected text inside the card with the primary accent so
it reads as "yes, you can copy this" rather than the default browser
selection color. */
.tree-hovercard ::selection {
background: var(--primary-light);
color: var(--text);
}
.tree-hovercard__header {
margin-bottom: 0.35rem;
}
.tree-hovercard__title {
font-weight: 600;
font-size: 0.95rem;
line-height: 1.2;
color: var(--text);
word-break: break-word;
}
.tree-hovercard__sub {
margin-top: 0.15rem;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-muted);
letter-spacing: 0.01em;
}
.tree-hovercard__list {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.12rem 0.7rem;
align-items: baseline;
}
.tree-hovercard__key {
color: var(--text-muted);
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tree-hovercard__val {
color: var(--text);
font-size: 0.82rem;
word-break: break-word;
}
.tree-hovercard__val--mono {
font-family: var(--font-mono);
font-size: 0.78rem;
}
/* Archive-reference links inside the hovercard pick up the primary
accent so they read as clickable, and stay inline with the mono
font when they sit inside a mono cell. */
.tree-hovercard__val a {
color: var(--primary, #2868c8);
text-decoration: none;
}
.tree-hovercard__val a:hover {
text-decoration: underline;
}
/* Separator stretches across both grid columns. Bleed into the
card's padding so it visually reads as a divider, not a hairline. */
.tree-hovercard__sep {
grid-column: 1 / -1;
border-top: 1px solid var(--border);
margin: 0.25rem -0.7rem;
}

View file

@ -0,0 +1,312 @@
// accept-transmittal.js — the doc-controller "Accept Transmittal"
// workflow modal.
//
// Surfaced by events.js as a right-click item on a transmittal folder
// inside archive/<their-party>/incoming/. The folder name must conform
// to the ZDDC transmittal grammar (date_tracking (status) - title);
// every file inside must conform to ZDDC filename grammar with the
// same tracking. Non-conformance is flagged in the modal and the user
// cancels to ask the sender to fix.
//
// On submit, the form assembles a YAML body (received_date plus an
// optional plan-review chain block) and POSTs it with
// X-ZDDC-Op: accept-transmittal to the transmittal-folder URL. The
// server validates everything, moves the folder into received/,
// renames it to tracking-only, and optionally chains Plan Review.
(function () {
'use strict';
var REVIEW_OFFSET_DAYS = 7;
var RESPONSE_OFFSET_DAYS = 14;
function status(msg, level) {
var t = window.zddc && window.zddc.toast;
if (t) t(msg, level || 'info');
}
function isoDateToday() {
var d = new Date();
return d.getFullYear()
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
+ '-' + ('0' + d.getDate()).slice(-2);
}
function isoDatePlus(days) {
var d = new Date();
d.setDate(d.getDate() + days);
return d.getFullYear()
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
+ '-' + ('0' + d.getDate()).slice(-2);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
})[c];
});
}
// Is this node a direct child of an incoming/ canonical folder
// AND a well-formed transmittal folder? The first half is the
// cascade-driven scope check (X-ZDDC-Canonical-Folder == 'incoming'
// on the current listing's parent context); the second is a
// structural folder-name parse against the ZDDC grammar.
function isAcceptableTransmittalFolder(node) {
if (!node || !node.isDir) return false;
if (node.virtual) return false;
// The cascade signal is on the PARENT directory's listing, which
// is the directory whose contents are currently shown — i.e.
// state.currentPath. When the listing's scope is incoming/,
// every direct child folder is a candidate (validated by name
// here and by the server again on POST).
if (window.app.state.scopeCanonicalFolder !== 'incoming') return false;
var parsed = window.zddc.parseFolder(node.name);
return !!(parsed && parsed.valid);
}
// Scan the listing's tree node for files inside the transmittal
// folder and classify each as conforming (tracking matches the
// folder) or violating. Returns { ok: [...], violations: [...] }.
// Best-effort — operates only on already-loaded children. The
// server is authoritative; this is a UX hint.
function classifyChildren(node, folderTracking) {
var out = { ok: [], violations: [] };
var children = (node && node.children) ? node.children : [];
children.forEach(function (c) {
if (c.virtual) return;
if (c.isDir) {
out.violations.push(c.name + ': nested directories are not permitted');
return;
}
if (c.name.charAt(0) === '.') return; // dotfiles ignored
var parsed = window.zddc.parseFilename(c.name);
if (!parsed || !parsed.valid) {
out.violations.push(c.name + ': does not conform to ZDDC filename grammar');
return;
}
if (parsed.trackingNumber !== folderTracking) {
out.violations.push(c.name + ': tracking "' + parsed.trackingNumber
+ '" does not match folder tracking "' + folderTracking + '"');
return;
}
out.ok.push(c.name);
});
return out;
}
function fetchPeopleSuggestions() {
return fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
}).then(function (r) {
if (!r.ok) return [];
return r.json().then(function (data) {
var out = [];
if (data && data.email) out.push(data.email);
return out;
});
}).catch(function () { return []; });
}
function openForm(initial) {
return new Promise(function (resolve, reject) {
var overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div');
box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);font-family:inherit;';
var violationsHtml = '';
if (initial.violations && initial.violations.length) {
violationsHtml = '<div style="margin:0.5rem 0;padding:0.5rem 0.75rem;background:#fff3cd;border-left:3px solid #d39e00;font-size:0.85rem;">'
+ '<strong>Non-conforming files detected:</strong><ul style="margin:0.25rem 0 0 1rem;padding:0;">'
+ initial.violations.map(function (v) { return '<li>' + escapeHtml(v) + '</li>'; }).join('')
+ '</ul><p style="margin:0.4rem 0 0 0;">Cancel and contact the sender to correct these before re-uploading.</p></div>';
}
var planReviewFieldsHtml =
'<div id="acc-pr-fields" style="display:none;margin-top:0.6rem;padding:0.5rem 0.75rem;background:rgba(0,0,0,0.03);border-radius:4px;">' +
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.4rem 0.75rem;align-items:center;font-size:0.9rem;">' +
'<label for="acc-review-lead">Review lead</label>' +
'<input id="acc-review-lead" type="email" list="acc-people" style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of reviewing/<…>">' +
'<label for="acc-review-date">Plan review complete date</label>' +
'<input id="acc-review-date" type="date">' +
'<label for="acc-approver">Approver</label>' +
'<input id="acc-approver" type="email" list="acc-people" style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of staging/<…>">' +
'<label for="acc-response-date">Plan response date</label>' +
'<input id="acc-response-date" type="date">' +
'<datalist id="acc-people"></datalist>' +
'</div>' +
'</div>';
box.innerHTML =
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Accept Transmittal — ' + escapeHtml(initial.tracking) + '</h2>' +
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
'This will file <strong>' + initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + '</strong> from ' +
'<code>' + escapeHtml(initial.folder) + '</code> into the immutable received archive at ' +
'<code>archive/' + escapeHtml(initial.party) + '/received/' + escapeHtml(initial.tracking) + '/</code>. ' +
'Once filed, only document-control can add new files there; nothing can be edited or deleted.' +
'</p>' +
violationsHtml +
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.5rem 0.75rem;align-items:center;font-size:0.9rem;">' +
'<label for="acc-received-date">Received date</label>' +
'<input id="acc-received-date" type="date" required>' +
'</div>' +
'<label style="display:flex;align-items:center;gap:0.4rem;margin-top:0.8rem;font-size:0.9rem;">' +
'<input type="checkbox" id="acc-setup-pr">' +
'<span>Set up Plan Review now — scaffold the reviewing/ and staging/ folders for the response</span>' +
'</label>' +
planReviewFieldsHtml +
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
'<button type="button" id="acc-cancel">Cancel</button>' +
'<button type="button" id="acc-submit" class="btn-primary"' +
(initial.violations && initial.violations.length ? ' disabled' : '') + '>Accept</button>' +
'</div>';
overlay.appendChild(box);
document.body.appendChild(overlay);
box.querySelector('#acc-received-date').value = isoDateToday();
box.querySelector('#acc-review-date').value = isoDatePlus(REVIEW_OFFSET_DAYS);
box.querySelector('#acc-response-date').value = isoDatePlus(RESPONSE_OFFSET_DAYS);
var prCheckbox = box.querySelector('#acc-setup-pr');
var prFields = box.querySelector('#acc-pr-fields');
prCheckbox.addEventListener('change', function () {
prFields.style.display = prCheckbox.checked ? '' : 'none';
});
fetchPeopleSuggestions().then(function (emails) {
var dl = box.querySelector('#acc-people');
if (!dl) return;
emails.forEach(function (e) {
var opt = document.createElement('option');
opt.value = e;
dl.appendChild(opt);
});
});
function close() {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
}
box.querySelector('#acc-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled'));
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
document.addEventListener('keydown', function escHandler(e) {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
close(); reject(new Error('cancelled'));
}
});
box.querySelector('#acc-submit').addEventListener('click', function () {
var values = {
receivedDate: box.querySelector('#acc-received-date').value,
setupPlanReview: prCheckbox.checked,
reviewLead: box.querySelector('#acc-review-lead').value.trim(),
approver: box.querySelector('#acc-approver').value.trim(),
planReviewDate: box.querySelector('#acc-review-date').value,
planResponseDate: box.querySelector('#acc-response-date').value
};
if (!values.receivedDate) { status('Received date is required.', 'error'); return; }
if (values.setupPlanReview) {
if (!values.reviewLead || !values.approver
|| !values.planReviewDate || !values.planResponseDate) {
status('Plan Review fields are required when the checkbox is on.', 'error');
return;
}
}
close(); resolve(values);
});
});
}
function quote(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
function buildBody(values) {
var lines = ['received_date: ' + values.receivedDate];
if (values.setupPlanReview) {
lines.push('setup_plan_review: true');
lines.push('review_lead: ' + quote(values.reviewLead));
lines.push('approver: ' + quote(values.approver));
lines.push('plan_review_complete_date: ' + values.planReviewDate);
lines.push('plan_response_date: ' + values.planResponseDate);
}
lines.push('');
return lines.join('\n');
}
async function invoke(node) {
var tree = window.app.modules.tree;
if (!tree) return;
var url = tree.pathFor(node);
if (!url.endsWith('/')) url += '/';
var parsedFolder = window.zddc.parseFolder(node.name);
if (!parsedFolder || !parsedFolder.valid) {
status('Folder name does not conform to ZDDC transmittal grammar.', 'error');
return;
}
// Derive the party from the path: archive/<party>/incoming/<folder>/.
var parts = url.replace(/^\/+|\/+$/g, '').split('/');
var partyIdx = parts.indexOf('archive');
var party = (partyIdx >= 0 && parts[partyIdx + 1]) ? parts[partyIdx + 1] : '';
var classification = classifyChildren(node, parsedFolder.trackingNumber);
var values;
try {
values = await openForm({
tracking: parsedFolder.trackingNumber,
folder: node.name,
party: party,
fileCount: classification.ok.length,
violations: classification.violations
});
} catch (_e) {
return;
}
status('Accept Transmittal — submitting…');
var resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: {
'X-ZDDC-Op': 'accept-transmittal',
'Content-Type': 'application/yaml'
},
body: buildBody(values),
credentials: 'same-origin'
});
} catch (e) {
status('Accept failed: ' + (e && e.message ? e.message : e), 'error');
return;
}
if (!resp.ok) {
var text = '';
try { text = await resp.text(); } catch (_e) { /* ignore */ }
status('Accept failed (' + resp.status + '): ' + text, 'error');
return;
}
var data; try { data = await resp.json(); } catch (_e) { data = null; }
var msg = 'Accepted ' + (data && data.moved_files ? data.moved_files : '?') + ' file(s) into '
+ (data && data.received_path ? data.received_path : 'received/');
if (data && data.merged) msg += ' (merged with existing tracking)';
if (data && data.plan_review) msg += ' · Plan Review scaffolded';
status(msg + ' — reload to see the move.', 'success');
}
window.app.modules.acceptTransmittal = {
isAcceptableTransmittalFolder: isAcceptableTransmittalFolder,
invoke: invoke
};
})();

View file

@ -19,9 +19,80 @@
// Expose for events.js's client-side rescope on dblclick.
window.app.modules.augmentRoot = passThroughEntries;
// Walk a `?file=` path segment-by-segment from the current root.
// Each non-leaf segment is matched against the parent's children
// by name; if found and it's a folder, expand+load it (so its
// children populate state.nodes) and recurse into them. The leaf
// segment becomes the selected/previewed entry. Silently no-ops
// when any segment doesn't resolve — deep links aren't a hard
// contract, just an affordance.
async function openDeepLink(path) {
var segs = path.split('/').filter(Boolean);
if (segs.length === 0) return;
var tree = window.app.modules.tree;
var prev = window.app.modules.preview;
// Lookup helper: find a node by name within a given parent's
// immediate children. Top-level walk uses state.rootIds.
function findChild(parentIds, name) {
for (var i = 0; i < parentIds.length; i++) {
var n = window.app.state.nodes.get(parentIds[i]);
if (n && n.name === name) return n;
}
return null;
}
var ids = window.app.state.rootIds;
for (var i = 0; i < segs.length; i++) {
var node = findChild(ids, segs[i]);
if (!node) return; // segment not present in this listing
if (i === segs.length - 1) {
// Leaf — select + preview.
window.app.state.selectedId = node.id;
window.app.state.lastPreviewedNodeId = node.id;
tree.render();
if (prev && !node.isDir) prev.showFilePreview(node);
return;
}
// Intermediate — must be a folder we can expand into.
if (!(node.isDir || node.isZip)) return;
if (!node.loaded) {
await tree.toggleFolder(node.id); // loads + sets expanded
} else if (!node.expanded) {
node.expanded = true;
}
ids = node.childIds;
}
}
async function bootstrap() {
events.init();
// Honor ?file=<path> deep links: external clients (the profile
// page's "edit your .zddc files" list, future bookmarks, etc.)
// can link directly to "open browse at <dir>, with this entry
// selected and previewed". Single-segment names (?file=foo.md)
// match in the current directory; multi-segment paths
// (?file=a/b/foo.md) walk into a/ then b/ then open foo.md,
// loading intermediate directories on the way.
//
// When the LEAF (or any intermediate segment) is hidden
// (.zddc, .form.yaml, …), flip showHidden ON BEFORE the
// initial listing fetch so dotfiles appear in the tree.
var qs = new URLSearchParams(location.search);
var deepFile = qs.get('file');
// Explicit ?hidden=1 in the URL: restore the show-hidden toggle
// on reload (the URL is the persistence layer for this flag —
// see events.js syncURLToSelection).
if (qs.get('hidden') === '1') state.showHidden = true;
if (deepFile) {
var segs = deepFile.split('/').filter(Boolean);
for (var si = 0; si < segs.length; si++) {
var c = segs[si].charAt(0);
if (c === '.' || c === '_') { state.showHidden = true; break; }
}
}
// Try server auto-detect. If this page is served by zddc-server
// (or any server with a Caddy-shaped JSON listing), load the
// current directory automatically. Otherwise show the empty
@ -40,6 +111,14 @@
// response, re-resolve so an /incoming URL auto-activates
// grid mode.
if (events.applyResolvedViewMode) events.applyResolvedViewMode();
// Final step of the deep link: walk the path segment by
// segment, expanding + loading intermediate directories
// before opening the leaf. Single-segment names use the
// same code path with one iteration.
if (deepFile) {
await openDeepLink(deepFile);
}
}
// Else: empty state stays visible; user can click Select Directory.
@ -50,6 +129,9 @@
if (window.app.state.source !== 'server') return;
var path = location.pathname;
if (!path.endsWith('/')) path += '/';
var popQS = new URLSearchParams(location.search);
if (popQS.get('hidden') === '1') window.app.state.showHidden = true;
else window.app.state.showHidden = false;
try {
var es = await loader.fetchServerChildren(path);
window.app.state.currentPath = path;
@ -63,6 +145,10 @@
if (previewTitle) previewTitle.textContent = 'No file selected';
// Reapply view mode for the new URL (incoming/ → grid, etc).
if (events.applyResolvedViewMode) events.applyResolvedViewMode();
// Re-walk ?file= so back/forward restores selection +
// expansion, not just scope.
var popFile = popQS.get('file');
if (popFile) await openDeepLink(popFile);
} catch (_e) { /* swallow — leave the tree as-is */ }
});
}

View file

@ -0,0 +1,146 @@
// create-transmittal.js — folder-creation plumbing for outgoing
// transmittals.
//
// Surfaced by events.js as a pane-menu item (right-click empty space)
// when state.scopeCanonicalFolder == 'staging'. The modal prompts for
// a ZDDC-conforming folder name (date_tracking (purpose) - subject)
// with live validation via zddc.parseFolder, then POSTs X-ZDDC-Op:
// mkdir. On success the client navigates to the new folder URL — the
// staging/ cascade serves the transmittal tool there, where the user
// builds the manifest, adds files, and publishes.
//
// No manifest assembly happens here. This is plumbing.
(function () {
'use strict';
function status(msg, level) {
var t = window.zddc && window.zddc.toast;
if (t) t(msg, level || 'info');
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c];
});
}
function isoDateToday() {
var d = new Date();
return d.getFullYear()
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
+ '-' + ('0' + d.getDate()).slice(-2);
}
function openForm() {
return new Promise(function (resolve, reject) {
var overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div');
box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);';
box.innerHTML =
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Create Transmittal folder</h2>' +
'<p style="margin:0 0 0.6rem 0;font-size:0.85rem;color:#666;">' +
"After it's created, the transmittal tool opens here so you can build the manifest — " +
'add rows from the MDL, choose revisions, and associate files.' +
'</p>' +
'<label for="ct-name" style="font-size:0.9rem;">Folder name (ZDDC convention)</label>' +
'<input id="ct-name" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" ' +
'placeholder="YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT" value="' + escapeHtml(isoDateToday() + '_') + '">' +
'<div id="ct-feedback" style="font-size:0.8rem;color:#888;margin-top:0.2rem;min-height:1.1em;"></div>' +
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
'<button type="button" id="ct-cancel">Cancel</button>' +
'<button type="button" id="ct-submit" class="btn-primary" disabled>Create</button>' +
'</div>';
overlay.appendChild(box);
document.body.appendChild(overlay);
var input = box.querySelector('#ct-name');
var submit = box.querySelector('#ct-submit');
var feedback = box.querySelector('#ct-feedback');
function revalidate() {
var v = input.value.trim();
if (!v) {
feedback.textContent = '';
submit.disabled = true;
return;
}
var parsed = window.zddc.parseFolder(v);
if (parsed && parsed.valid) {
feedback.style.color = '#2a8';
feedback.textContent = '✓ tracking=' + parsed.trackingNumber +
', status=' + parsed.status + ', title=' + parsed.title;
submit.disabled = false;
} else {
feedback.style.color = '#c33';
feedback.textContent = '✗ does not match YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT';
submit.disabled = true;
}
}
input.addEventListener('input', revalidate);
revalidate();
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
box.querySelector('#ct-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled'));
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
document.addEventListener('keydown', function escHandler(e) {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
close(); reject(new Error('cancelled'));
}
});
submit.addEventListener('click', function () {
var v = input.value.trim();
var parsed = window.zddc.parseFolder(v);
if (!parsed || !parsed.valid) {
status('Folder name must conform to ZDDC convention.', 'error');
return;
}
close(); resolve({ folderName: v });
});
// Position cursor after the date prefix.
setTimeout(function () {
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}, 0);
});
}
async function invoke() {
if (window.app.state.scopeCanonicalFolder !== 'staging') {
status('Create Transmittal folder is only available inside staging/.', 'error');
return;
}
var stagingUrl = window.app.state.currentPath || '/';
if (!stagingUrl.endsWith('/')) stagingUrl += '/';
var choice;
try { choice = await openForm(); } catch (_e) { return; }
var newUrl = stagingUrl + encodeURIComponent(choice.folderName) + '/';
var resp;
try {
resp = await fetch(newUrl, {
method: 'POST',
headers: { 'X-ZDDC-Op': 'mkdir' },
credentials: 'same-origin'
});
} catch (e) {
status('Create failed: ' + (e && e.message ? e.message : e), 'error');
return;
}
if (!resp.ok) {
var text = ''; try { text = await resp.text(); } catch (_e) {}
status('Create failed (' + resp.status + '): ' + text, 'error');
return;
}
status('Created ' + choice.folderName + ' — opening transmittal tool…', 'success');
// Navigate to the new folder (no-slash form → default_tool: transmittal).
window.location.href = stagingUrl + encodeURIComponent(choice.folderName);
}
window.app.modules.createTransmittal = { invoke: invoke };
})();

View file

@ -1,13 +1,17 @@
// download.js — "Download (zip)" for the currently-viewed directory.
// download.js — per-node downloads, surfaced through the tree's
// right-click menu (downloadFile / downloadFolder).
//
// Server mode: just point an <a download> at "<currentPath>?zip=1" —
// zddc-server streams an ACL-filtered .zip of the subtree, so nothing
// is held in the browser.
// downloadFile: a single file. Server mode lets the browser pull
// node.url (zddc-server emits Content-Disposition); FS-API mode
// reads bytes through the file handle and blob-downloads.
//
// FS-API (offline) mode: there's no server, so we walk the picked
// folder ourselves, bundle every file with JSZip, and download the
// blob. A two-pass walk (metadata first, then bytes) lets us warn
// before loading a very large tree into memory.
// downloadFolder: an arbitrary directory node as a .zip. Server
// mode points an <a download> at the virtual "<node-path>.zip"
// URL — zddc-server recognises the suffix and streams an ACL-
// filtered archive without buffering on the client. FS-API mode
// walks the picked handle in two passes — metadata first, then
// bytes — so we can warn before loading a very large tree into
// memory.
(function () {
'use strict';
@ -103,39 +107,75 @@
ev.statusInfo('Downloaded ' + rootHandle.name + '.zip (' + files.length + ' files)');
}
function downloadServerSubtree() {
var dir = (state.currentPath || '/').replace(/\/$/, '');
var name = (dir.split('/').filter(Boolean).pop()) || 'download';
events().statusInfo('Preparing ' + name + '.zip…');
downloadUrl(name + '.zip', dir + '/?zip=1');
// The browser owns the download from here; clear the hint shortly.
setTimeout(function () { events().statusClear(); }, 2500);
}
var busy = false;
async function downloadCurrentSubtree() {
// Download a single file node. Server mode: rely on the node's
// own URL (the server emits Content-Disposition). FS mode: read
// bytes through the handle and trigger a blob download. Works
// for ordinary files, for .zip members (the loader sets node.url
// for zip members in server mode and a ZipFileHandle offline),
// and for the .zip file itself.
async function downloadFile(node) {
if (busy) return;
var btn = document.getElementById('downloadZipBtn');
if (!node || node.isDir) {
events().statusError('Not a file: ' + (node && node.name));
return;
}
busy = true;
if (btn) btn.disabled = true;
try {
if (state.source === 'server') {
downloadServerSubtree();
} else if (state.source === 'fs' && state.rootHandle) {
await downloadFsSubtree(state.rootHandle);
if (node.url) {
events().statusInfo('Downloading ' + node.name + '…');
downloadUrl(node.name, node.url);
setTimeout(function () { events().statusClear(); }, 2500);
} else if (node.handle && typeof node.handle.getFile === 'function') {
events().statusInfo('Preparing ' + node.name + '…');
var f = await node.handle.getFile();
var blob = new Blob([await f.arrayBuffer()]);
downloadBlob(node.name, blob);
events().statusInfo('Downloaded ' + node.name);
} else {
events().statusError('Nothing to download — open a directory first.');
events().statusError('No download path for ' + node.name);
}
} catch (e) {
events().statusError('Download failed: ' + (e && e.message ? e.message : e));
} finally {
busy = false;
}
}
// Download an arbitrary folder node as a .zip. Server mode points
// an <a download> at the virtual "<node-path>.zip" URL (the
// dispatcher recognises the suffix and streams the subtree). FS
// mode walks the directory handle.
async function downloadFolder(node) {
if (busy) return;
if (!node || !node.isDir) {
events().statusError('Not a folder: ' + (node && node.name));
return;
}
busy = true;
try {
if (state.source === 'server') {
var tree = window.app.modules.tree;
var dir = tree.pathFor(node).replace(/\/$/, '');
events().statusInfo('Preparing ' + node.name + '.zip…');
downloadUrl(node.name + '.zip', dir + '.zip');
setTimeout(function () { events().statusClear(); }, 2500);
} else if (state.source === 'fs' && node.handle
&& node.handle.kind === 'directory') {
await downloadFsSubtree(node.handle);
} else {
events().statusError('Cannot download ' + node.name);
}
} catch (e) {
events().statusError('Download failed: ' + (e && e.message ? e.message : e));
} finally {
busy = false;
if (btn) btn.disabled = false;
}
}
window.app.modules.download = {
downloadCurrentSubtree: downloadCurrentSubtree
downloadFile: downloadFile,
downloadFolder: downloadFolder
};
})();

View file

@ -69,7 +69,6 @@
function applySourceUI() {
var add = document.getElementById('addDirectoryBtn');
var refresh = document.getElementById('refreshHeaderBtn');
var dlZip = document.getElementById('downloadZipBtn');
if (add) {
if (state.source === 'server') {
add.classList.remove('btn-primary');
@ -86,18 +85,59 @@
refresh.classList.add('hidden');
}
}
// "Download (zip)" is meaningful once a directory is loaded
// (server or local); it zips the directory currently in view.
if (dlZip) {
if (state.source) {
dlZip.classList.remove('hidden');
} else {
dlZip.classList.add('hidden');
}
// syncURLToSelection reflects the current scope + selected node +
// show-hidden flag into the URL bar via history.replaceState, so:
// - bookmarks / copy-paste of the URL re-open the same view
// - reload (e.g. after toggling admin mode, which forces a hard
// reload to pick up the elevated cookie) lands the user back
// on the same selection
//
// Uses replaceState (not pushState) so a long click sequence doesn't
// pollute browser history. Scope changes (rescopeServer) still
// pushState — that's the only "intentional" navigation step in the
// SPA, and back/forward should walk between scopes, not selections.
//
// FS-API mode has no shareable URL, so this is a no-op there.
function syncURLToSelection() {
if (state.source !== 'server') return;
var scope = state.currentPath || '/';
if (!scope.endsWith('/')) scope += '/';
var params = new URLSearchParams();
var node = state.selectedId != null ? state.nodes.get(state.selectedId) : null;
if (node) {
var abs = tree.pathFor(node);
var prefix = scope.replace(/\/$/, '');
var rel = abs;
if (prefix && abs.indexOf(prefix + '/') === 0) {
rel = abs.slice(prefix.length + 1);
}
// Directory selections get a trailing slash so the URL
// round-trips as a navigable folder reference.
if (node.isDir && rel && !rel.endsWith('/')) rel += '/';
if (rel) params.set('file', rel);
}
if (state.showHidden) params.set('hidden', '1');
// URLSearchParams percent-encodes '/' to %2F; the server doesn't
// care, but the URL bar reads better with raw slashes.
var qs = params.toString().replace(/%2F/g, '/');
var url = scope + (qs ? '?' + qs : '');
try {
history.replaceState({ zddcBrowse: true, path: url }, '', url);
} catch (_e) { /* private browsing edge cases */ }
}
async function refreshListing() {
// Snapshot expanded paths + selection BEFORE setRoot clears the
// tree, then re-apply after the new root is in place. Keeps
// the user's layout (which folders were open, which row was
// highlighted, what the preview was pinned to) stable across
// a refresh — including the auto-refresh triggered by the
// "Show hidden files" toggle.
var snap = tree.snapshotState();
if (state.source === 'server') {
var raw;
try {
@ -107,6 +147,7 @@
return;
}
tree.setRoot(raw);
await tree.restoreState(snap);
tree.render();
statusInfo('Refreshed (' + raw.length + ' item'
+ (raw.length === 1 ? '' : 's') + ')');
@ -119,6 +160,7 @@
return;
}
tree.setRoot(raw2);
await tree.restoreState(snap);
tree.render();
statusInfo('Refreshed');
}
@ -132,38 +174,31 @@
var refresh = document.getElementById('refreshHeaderBtn');
if (refresh) refresh.addEventListener('click', refreshListing);
var dlZip = document.getElementById('downloadZipBtn');
if (dlZip) dlZip.addEventListener('click', function () {
var d = window.app.modules.download;
if (d) d.downloadCurrentSubtree();
});
// Sort dropdown — change → tree re-renders with the new sort.
// Format of option value: "<key>:<asc|desc>". Defaults match
// state.sort initial values (name:asc).
var sortSel = document.getElementById('sortBy');
if (sortSel) {
sortSel.value = state.sort.key + ':' + (state.sort.dir > 0 ? 'asc' : 'desc');
sortSel.addEventListener('change', function () {
var parts = sortSel.value.split(':');
var key = parts[0];
var dir = parts[1] === 'desc' ? -1 : 1;
tree.setSortExplicit(key, dir);
// Tree autofilter — parses input through zddc.filter.parse so
// the same query grammar that the archive app uses (terms,
// quotes, !negation, multi-word AND) works here. The AST is
// cached on state.filterAST; tree.render reads it and skips
// non-matching rows. Escape clears.
var filterInput = document.getElementById('treeFilter');
if (filterInput) {
var filterDebounce = null;
var applyFilter = function () {
var raw = filterInput.value || '';
state.filterText = raw;
state.filterAST = raw ? window.zddc.filter.parse(raw) : null;
filterInput.classList.toggle('filter-active', !!raw);
tree.render();
};
filterInput.addEventListener('input', function () {
if (filterDebounce) clearTimeout(filterDebounce);
filterDebounce = setTimeout(applyFilter, 80);
});
}
// "Show hidden" checkbox — toggles state.showHidden, which the
// loader reads to append ?hidden=1 to listing requests. Re-uses
// the existing refreshListing flow so the tree pulls a fresh
// listing. ACL is still server-side; this just relaxes the
// client-visible filter for entries the user is already
// allowed to read.
var hiddenCb = document.getElementById('showHidden');
if (hiddenCb) {
hiddenCb.checked = !!state.showHidden;
hiddenCb.addEventListener('change', function () {
state.showHidden = hiddenCb.checked;
refreshListing();
filterInput.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && filterInput.value) {
e.preventDefault();
filterInput.value = '';
applyFilter();
}
});
}
@ -281,6 +316,7 @@
state.selectedId = id;
state.lastPreviewedNodeId = id;
tree.render(); // refresh selection highlight
syncURLToSelection();
var p = previewMod();
if (p) p.showFilePreview(node);
});
@ -314,9 +350,734 @@
}
navigateIntoFolder(node);
});
// Keyboard navigation in the tree. Document-level listener so
// the user doesn't have to click into the tree first; bails
// out cleanly when focus is in an editable field or when a
// modal / context-menu owns the keys. Roving-tabindex-style
// semantics, matching the W3C tree-view pattern:
//
// ↓ / ↑ — move selection (auto-previews files)
// → — expand if collapsed; jump to first child
// if already expanded; no-op otherwise
// ← — collapse if expanded; jump to parent
// if collapsed/leaf
// Enter / Space — preview file / toggle folder
// Home / End — first / last visible row
document.addEventListener('keydown', function (e) {
// Skip editable contexts.
var tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (e.target && e.target.isContentEditable) return;
// Skip when a modal or context menu is open.
if (document.querySelector('.modal-overlay, .zddc-menu')) return;
// Skip if any modifier is pressed — lets Ctrl-F, Cmd-T,
// Alt-arrow back/forward etc. fall through unchanged.
if (e.ctrlKey || e.metaKey || e.altKey) return;
var key = e.key;
var navKey = key === 'ArrowDown' || key === 'ArrowUp'
|| key === 'ArrowLeft' || key === 'ArrowRight'
|| key === 'Home' || key === 'End'
|| key === 'Enter' || key === ' ';
if (!navKey) return;
var visible = tree.visibleIds();
if (!visible.length) return;
// Commit to handling this key — preventDefault so the
// browser doesn't also scroll on arrows / page-down on
// Space. Selection / expand actions happen below.
e.preventDefault();
var curIdx = visible.indexOf(state.selectedId);
var node = state.selectedId != null
? state.nodes.get(state.selectedId) : null;
var expandable = !!(node && (node.isDir || node.isZip));
var nextId = null;
var previewModule = previewMod();
if (key === 'ArrowDown') {
nextId = curIdx < 0
? visible[0]
: visible[Math.min(curIdx + 1, visible.length - 1)];
} else if (key === 'ArrowUp') {
nextId = curIdx < 0
? visible[visible.length - 1]
: visible[Math.max(curIdx - 1, 0)];
} else if (key === 'Home') {
nextId = visible[0];
} else if (key === 'End') {
nextId = visible[visible.length - 1];
} else if (key === 'ArrowRight' && node) {
if (expandable && !node.expanded) {
tree.toggleFolder(node.id);
return;
}
if (expandable && node.expanded
&& node.childIds && node.childIds.length) {
nextId = node.childIds[0];
}
} else if (key === 'ArrowLeft' && node) {
if (expandable && node.expanded) {
tree.toggleFolder(node.id);
return;
}
if (node.parentId != null) {
nextId = node.parentId;
}
} else if ((key === 'Enter' || key === ' ') && node) {
if (expandable) {
tree.toggleFolder(node.id);
} else if (previewModule) {
previewModule.showFilePreview(node);
state.lastPreviewedNodeId = node.id;
}
return;
}
if (nextId == null) return;
state.selectedId = nextId;
var nextNode = state.nodes.get(nextId);
tree.render();
syncURLToSelection();
// Auto-preview files as the keyboard cursor lands on them
// so the right pane keeps up with selection. Folders are
// selection-only; their preview is "expand to see inside".
if (nextNode && !nextNode.isDir && !nextNode.isZip
&& previewModule) {
previewModule.showFilePreview(nextNode);
state.lastPreviewedNodeId = nextId;
}
// Scroll the now-selected row into view.
var newRow = treeBody.querySelector(
'.tree-row[data-id="' + nextId + '"]');
if (newRow) newRow.scrollIntoView({ block: 'nearest' });
});
// Right-click → context menu. Two surfaces:
// - on a tree row: per-row menu (Open, Rename, Delete, …)
// - on empty space in the pane: directory-scope menu
// (New folder, Refresh, Sort by, …)
treeBody.addEventListener('contextmenu', function (e) {
e.preventDefault();
var row = e.target.closest('.tree-row');
if (row) {
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (!node) return;
state.selectedId = id;
tree.render();
syncURLToSelection();
window.zddc.menu.open({
x: e.clientX,
y: e.clientY,
context: { node: node, row: row },
items: buildTreeRowMenu
});
} else {
window.zddc.menu.open({
x: e.clientX,
y: e.clientY,
context: { dir: state.currentPath || '/' },
items: buildPaneMenu
});
}
});
// Per-row drag-drop. Any row is a drop target — folders
// upload into themselves; files upload into their parent
// folder. Highlighting is purely visual; server-side ACL
// is the source of truth (a 403 surfaces as an error toast).
wirePerRowDrop(treeBody);
}
}
// ── Per-row drag/drop targets ─────────────────────────────────────────
// Translate a node into the directory that should receive uploads
// dropped onto its row. Folders → themselves; files → their parent.
// Returns a server path with a trailing slash, or null when there's
// no usable destination (offline mode, virtual node, etc.).
function targetDirForNode(node) {
if (!node || node.virtual) return null;
if (state.source !== 'server') return null;
if (node.isZip) return null; // can't upload INTO a zip via PUT
var dirNode = node;
if (!node.isDir) {
if (node.parentId == null) {
// Top-level file → upload to current scope.
return state.currentPath || '/';
}
dirNode = state.nodes.get(node.parentId);
if (!dirNode) return null;
}
var p = tree.pathFor(dirNode);
if (!p.endsWith('/')) p += '/';
return p;
}
// True when this node is a file viewed through the synthetic
// <workflow>/received/ window — the URL has a `received/` segment
// that's NOT preceded by `archive/<party>/` (the canonical record
// form). A drop here is a review-comment intent: server rewrites to
// <workflow>/<base>+C<n><suffix>.
function isVirtualReceivedFile(node) {
if (!node || node.isDir || state.source !== 'server') return false;
var url = tree.pathFor(node);
var parts = url.replace(/^\/+/, '').split('/');
var idx = parts.indexOf('received');
if (idx < 2) return false;
// Canonical form: parts[idx - 2] === 'archive'. Virtual form: anything else.
return parts[idx - 2].toLowerCase() !== 'archive';
}
function dragHasFiles(e) {
if (!e.dataTransfer || !e.dataTransfer.types) return false;
var types = e.dataTransfer.types;
for (var i = 0; i < types.length; i++) {
if (types[i] === 'Files') return true;
}
return false;
}
function wirePerRowDrop(treeBody) {
var lastOver = null;
function clearHighlight() {
if (lastOver) {
lastOver.classList.remove('is-droptarget');
lastOver = null;
}
}
treeBody.addEventListener('dragover', function (e) {
if (!dragHasFiles(e)) return;
var row = e.target.closest('.tree-row');
if (!row) { clearHighlight(); return; }
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (!node) return;
var dest = targetDirForNode(node);
if (!dest) {
if (e.dataTransfer) e.dataTransfer.dropEffect = 'none';
clearHighlight();
return;
}
e.preventDefault(); // signals "this is a drop target"
e.stopPropagation(); // suppress doc-level overlay
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
if (lastOver !== row) {
clearHighlight();
row.classList.add('is-droptarget');
lastOver = row;
}
});
treeBody.addEventListener('dragleave', function (e) {
// dragleave fires on row crossings too — only clear when the
// pointer actually leaves the tree body.
if (!e.relatedTarget || !treeBody.contains(e.relatedTarget)) {
clearHighlight();
}
});
treeBody.addEventListener('drop', async function (e) {
if (!dragHasFiles(e)) return;
var row = e.target.closest('.tree-row');
clearHighlight();
if (!row) return;
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (!node) return;
// Comment-upload short-circuit: drop on a file that lives
// under the virtual <workflow>/received/ window is a "comment
// on this file" intent. PUT to the target's URL — the server
// rewrites to <workflow>/<base>+C<n><suffix> and the canonical
// record (WORM) stays untouched. Confirm first so the user
// sees what's about to happen.
if (!node.isDir && isVirtualReceivedFile(node)) {
e.preventDefault();
e.stopPropagation();
if (!window.confirm("Drop bytes here as a review comment on '" + node.name + "'? The server will save it in the workflow folder with a +C<n> revision modifier.")) {
return;
}
var upMod = window.app.modules.upload;
if (!upMod) return;
var targetURL = tree.pathFor(node);
try {
await upMod.uploadCommentToTarget(targetURL, e.dataTransfer);
} catch (err) {
statusError('Comment upload failed: ' + (err.message || err));
}
return;
}
var dest = targetDirForNode(node);
if (!dest) return;
e.preventDefault();
e.stopPropagation(); // pre-empt doc-level handler
var up = window.app.modules.upload;
if (!up) return;
try {
await up.uploadToDir(dest, e.dataTransfer);
} catch (err) {
statusError('Upload failed: ' + (err.message || err));
}
});
}
// ── Create new folder / file (server mode) ────────────────────────────
// Reject names with path separators, leading dots, or empty input —
// mirrors the server-side hidden-segment / no-traversal guards so
// the user sees the rejection without a round-trip.
function validateName(name) {
name = (name || '').trim();
if (!name) return { ok: false, msg: 'Name required.' };
if (name.indexOf('/') !== -1) return { ok: false, msg: 'No slashes allowed.' };
if (name === '.' || name === '..') return { ok: false, msg: 'Invalid name.' };
if (name.charAt(0) === '.' || name.charAt(0) === '_') {
return { ok: false, msg: 'Names beginning with "." or "_" are reserved.' };
}
return { ok: true, name: name };
}
// Resolve "the directory new items go into" for a given row.
// Folders/zips: create inside them. Files: create alongside (in
// their parent). Used by the row-context New menu items.
function parentDirFor(node) {
var parentDir;
if (!node) {
parentDir = state.currentPath || '/';
} else if (node.isDir || node.isZip) {
parentDir = tree.pathFor(node);
} else if (node.parentId != null) {
var parent = state.nodes.get(node.parentId);
parentDir = parent ? tree.pathFor(parent) : (state.currentPath || '/');
} else {
parentDir = state.currentPath || '/';
}
if (!parentDir.endsWith('/')) parentDir += '/';
return parentDir;
}
async function createInDir(parentDir, kind) {
var up = window.app.modules.upload;
if (!up) return;
var promptMsg = kind === 'folder'
? 'New folder name (under ' + parentDir + '):'
: 'New markdown filename (under ' + parentDir + '):';
var defaultName = kind === 'folder' ? 'new-folder' : 'new.md';
var raw = window.prompt(promptMsg, defaultName);
if (raw == null) return;
var v = validateName(raw);
if (!v.ok) {
statusError(v.msg);
return;
}
try {
if (kind === 'folder') {
await up.makeDir(parentDir, v.name);
statusInfo('Created folder ' + v.name);
} else {
var name = /\.(md|markdown)$/i.test(v.name) ? v.name : v.name + '.md';
var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n';
await up.makeFile(parentDir, name, template, 'text/markdown; charset=utf-8');
statusInfo('Created ' + name);
}
await reloadDir(parentDir);
} catch (e) {
statusError('Create failed: ' + (e.message || e));
}
}
function createInside(node, kind) { return createInDir(parentDirFor(node), kind); }
// Reload a directory's children in the tree so a create/delete/
// rename is reflected. Works for both the current scope (root)
// and any expanded subdirectory.
async function reloadDir(dirPath) {
var loader = window.app.modules.loader;
if (!loader) return;
if (!dirPath.endsWith('/')) dirPath += '/';
// Root-scope reload — refresh the visible top-level listing.
if (dirPath === state.currentPath) {
try {
var es = state.source === 'server'
? await loader.fetchServerChildren(dirPath)
: (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []);
tree.setRoot(es);
} catch (_e) { /* swallow */ }
tree.render();
return;
}
// Otherwise find the node whose path matches and reload it.
var noSlash = dirPath.replace(/\/$/, '');
var hit = null;
state.nodes.forEach(function (n) {
if (hit || !n.isDir) return;
if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
});
if (hit) {
try {
var raw = state.source === 'server'
? await loader.fetchServerChildren(dirPath)
: (hit.handle ? await loader.fetchFsChildren(hit.handle) : []);
tree.setChildren(hit.id, raw);
hit.expanded = true;
} catch (_e) { /* swallow */ }
tree.render();
}
}
// ── Rename / Delete ───────────────────────────────────────────────────
async function renameNode(node) {
var up = window.app.modules.upload;
if (!up || !up.canMutate(node)) return;
var raw = window.prompt('Rename "' + node.name + '" to:', node.name);
if (raw == null) return;
var v = validateName(raw);
if (!v.ok) { statusError(v.msg); return; }
if (v.name === node.name) return;
try {
await up.renameNode(node, v.name);
statusInfo('Renamed to ' + v.name);
var parentPath = node.parentId != null
? tree.pathFor(state.nodes.get(node.parentId))
: (state.currentPath || '/');
await reloadDir(parentPath);
} catch (e) {
statusError('Rename failed: ' + (e.message || e));
}
}
async function deleteNode(node) {
var up = window.app.modules.upload;
if (!up || !up.canMutate(node)) return;
var what = node.isDir ? 'folder' : 'file';
// Native confirm() is intentional — destructive actions
// benefit from the browser's blocking, OS-styled dialog
// (signals "this is serious"). A custom modal would look
// friendlier; we want it to NOT look friendly.
var msg = 'Permanently delete this ' + what + '?\n\n' + node.name;
if (node.isDir) {
msg += '\n\nThis will remove every file inside it.';
}
if (!window.confirm(msg)) return;
try {
await up.removeNode(node);
statusInfo('Deleted ' + node.name);
// Clear selection / preview when they pointed at the
// now-gone node, so the right pane doesn't keep a ghost.
if (state.selectedId === node.id) {
state.selectedId = null;
syncURLToSelection();
}
if (state.lastPreviewedNodeId === node.id) {
state.lastPreviewedNodeId = null;
var pb = document.getElementById('previewBody');
if (pb) pb.innerHTML =
'<div class="preview-empty">Click a file in the tree to preview it.</div>';
var pt = document.getElementById('previewTitle');
if (pt) pt.textContent = 'No file selected';
var pm = document.getElementById('previewMeta');
if (pm) pm.textContent = '';
}
var parentPath = node.parentId != null
? tree.pathFor(state.nodes.get(node.parentId))
: (state.currentPath || '/');
await reloadDir(parentPath);
} catch (e) {
statusError('Delete failed: ' + (e.message || e));
}
}
// Shared submenu (used by both the row menu and the pane menu).
// Toggle items so the active sort is checked in both surfaces.
var SORT_BY_ITEMS = [
{ label: 'Name',
checked: function () { return state.sort.key === 'name'; },
action: function () { tree.setSortExplicit('name', 1); } },
{ label: 'Modified',
checked: function () { return state.sort.key === 'date'; },
action: function () { tree.setSortExplicit('date', -1); } },
{ label: 'Size',
checked: function () { return state.sort.key === 'size'; },
action: function () { tree.setSortExplicit('size', -1); } },
{ label: 'Type',
checked: function () { return state.sort.key === 'ext'; },
action: function () { tree.setSortExplicit('ext', 1); } }
];
// Row context menu — traditional file-manager layout:
// Open / Open in new tab / Pop out preview
// ─
// Download (label flips on type)
// ─
// New folder / New markdown file
// ─
// Rename / Delete (permission-gated, disabled
// when the row can't be mutated)
// ─
// Copy path / Copy name
// ─
// Expand / Collapse / Navigate into
// ─
// Sort by … / Show hidden files
//
// Items are kept VISIBLE but DISABLED when they don't apply, so
// every menu has the same shape regardless of what the user
// right-clicked. Predictable position = muscle memory.
function buildTreeRowMenu(ctx) {
var serverMode = state.source === 'server';
var canMutate = function (c) {
var up = window.app.modules.upload;
return !!(up && up.canMutate(c.node));
};
return [
// ── Open / preview cluster ──
{
label: function (c) {
if (c.node.isDir) return 'Open';
if (c.node.isZip) return 'Open archive';
return 'Preview';
},
disabled: function (c) { return !!c.node.virtual; },
action: function (c) {
if (c.node.isDir || c.node.isZip) {
tree.toggleFolder(c.node.id);
} else {
var p = previewMod();
if (p) p.showFilePreview(c.node);
}
}
},
{
label: 'Open in new tab',
accel: 'Ctrl+Click',
disabled: function (c) { return !c.node.url; },
action: function (c) {
if (c.node.url) window.open(c.node.url, '_blank', 'noopener');
}
},
{
label: 'Pop out preview',
disabled: function (c) { return c.node.isDir || c.node.isZip; },
action: function (c) {
var p = previewMod();
if (p) p.showFilePreview(c.node, { popup: true });
}
},
{ separator: true },
// ── Download (single item; label flips on type) ──
{
label: function (c) { return c.node.isDir ? 'Download ZIP' : 'Download'; },
icon: '⤓',
disabled: function (c) { return !!c.node.virtual; },
action: function (c) {
var d = window.app.modules.download;
if (!d) return;
if (c.node.isDir) d.downloadFolder(c.node);
else d.downloadFile(c.node);
}
},
{ separator: true },
// ── Create new (in the row's parent folder) ──
{
label: 'New folder',
disabled: !serverMode,
action: function (c) { createInside(c.node, 'folder'); }
},
{
label: 'New markdown file',
disabled: !serverMode,
action: function (c) { createInside(c.node, 'markdown'); }
},
{ separator: true },
// ── Rename + Delete (the permission-gated pair) ──
{
label: 'Rename…',
disabled: function (c) { return !canMutate(c); },
action: function (c) { renameNode(c.node); }
},
{
label: 'Delete…',
icon: '🗑',
danger: true,
disabled: function (c) { return !canMutate(c); },
action: function (c) { deleteNode(c.node); }
},
{ separator: true },
// ── Clipboard / identifiers ──
{
label: 'Copy path',
action: function (c) {
var path = tree.pathFor(c.node);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(path).then(
function () { statusInfo('Copied: ' + path); },
function () { statusError('Clipboard copy denied'); }
);
} else {
statusInfo(path);
}
}
},
{
label: 'Copy name',
action: function (c) {
// Always include the file extension. node.name
// already does for normal listings, but re-joining
// via zddc.joinExtension is defensive against any
// upstream that ever returns the basename split.
var n = c.node.name;
var ext = c.node.ext;
if (!c.node.isDir && ext
&& !n.toLowerCase().endsWith('.' + ext.toLowerCase())) {
n = window.zddc.joinExtension(n, ext);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(n);
}
statusInfo('Copied: ' + n);
}
},
{ separator: true },
// ── Tree-view ops (folder/zip rows only) ──
{
label: 'Expand subtree',
accel: 'Shift+Click',
disabled: function (c) { return !(c.node.isDir || c.node.isZip); },
action: function (c) { tree.expandSubtree(c.node.id); }
},
{
label: 'Collapse subtree',
disabled: function (c) { return !(c.node.isDir || c.node.isZip); },
action: function (c) { tree.collapseSubtree(c.node.id); }
},
{
label: 'Navigate into',
accel: 'Dbl-click',
disabled: function (c) { return !c.node.isDir; },
action: function (c) { navigateIntoFolder(c.node); }
},
{ separator: true },
// ── Plan Review (received/<tracking>/ only, cascade-gated) ──
{
label: 'Plan Review…',
visible: function (c) {
if (!serverMode) return false;
if (!state.scopeOnPlanReview) return false;
var pr = window.app.modules.planReview;
if (!pr) return false;
return pr.isReceivedTrackingFolder(c.node);
},
action: function (c) {
var pr = window.app.modules.planReview;
if (pr) pr.invoke(c.node);
}
},
// ── Accept Transmittal (transmittal folder under incoming/) ──
{
label: 'Accept Transmittal…',
visible: function (c) {
if (!serverMode) return false;
var at = window.app.modules.acceptTransmittal;
if (!at) return false;
return at.isAcceptableTransmittalFolder(c.node);
},
action: function (c) {
var at = window.app.modules.acceptTransmittal;
if (at) at.invoke(c.node);
}
},
// ── Stage / Unstage (files under working/ or staging/) ──
{
label: 'Stage to…',
visible: function (c) {
if (!serverMode) return false;
var s = window.app.modules.stage;
return !!(s && s.isStageableFile(c.node));
},
action: function (c) {
var s = window.app.modules.stage;
if (s) s.invokeStage(c.node);
}
},
{
label: 'Unstage to working/',
visible: function (c) {
if (!serverMode) return false;
var s = window.app.modules.stage;
return !!(s && s.isUnstageableFile(c.node));
},
action: function (c) {
var s = window.app.modules.stage;
if (s) s.invokeUnstage(c.node);
}
},
{ separator: true },
// ── View ──
{ label: 'Sort by', items: SORT_BY_ITEMS },
{ label: 'Show hidden files',
checked: function () { return !!state.showHidden; },
action: function () {
state.showHidden = !state.showHidden;
syncURLToSelection();
refreshListing();
} }
];
}
// Right-click on empty space in the tree pane → directory-scope
// menu. Operations apply to the current scope (state.currentPath),
// not any specific row.
function buildPaneMenu() {
var serverMode = state.source === 'server';
return [
{
label: 'New folder',
disabled: !serverMode,
action: function () { createInDir(state.currentPath || '/', 'folder'); }
},
{
label: 'New markdown file',
disabled: !serverMode,
action: function () { createInDir(state.currentPath || '/', 'markdown'); }
},
// ── Create Transmittal folder (staging/ scope only) ──
{
label: 'Create Transmittal folder…',
visible: function () {
return serverMode && state.scopeCanonicalFolder === 'staging';
},
action: function () {
var ct = window.app.modules.createTransmittal;
if (ct) ct.invoke();
}
},
{ separator: true },
{
label: 'Refresh',
accel: 'F5',
action: function () { refreshListing(); }
},
{ separator: true },
{ label: 'Sort by', items: SORT_BY_ITEMS },
{ label: 'Show hidden files',
checked: function () { return !!state.showHidden; },
action: function () {
state.showHidden = !state.showHidden;
syncURLToSelection();
refreshListing();
} }
];
}
// View mode is URL-driven, not UI-driven.
//
// ?view=grid → grid mode (only honored where classifier is
@ -419,10 +1180,14 @@
if (previewMeta) previewMeta.textContent = '';
// pushState so the URL bar reflects the new scope. A real
// reload would re-load browse at this URL (trailing slash →
// ServeDirectory → embedded browse SPA).
// ServeDirectory → embedded browse SPA). Then immediately
// replaceState via syncURLToSelection so the new URL also
// carries ?hidden=1 if the toggle is on (selection is null
// at the new scope; the query gets only `hidden`).
try {
history.pushState({ zddcBrowse: true, path: url }, '', url);
} catch (_e) { /* private browsing edge cases */ }
syncURLToSelection();
statusInfo('Entered ' + displayName);
// The new scope may have a different default view (grid inside
// incoming/, browse elsewhere). Re-resolve from the URL now

296
browse/js/hovercard.js Normal file
View file

@ -0,0 +1,296 @@
// hovercard.js — rich-metadata tooltip for tree rows.
//
// Replaces the native title="…" attribute with a custom card that
// surfaces every field we know about for a row: parsed ZDDC fields
// (trackingNumber / revision / status / title / date), type, size,
// modTime, on-server path, and URL. A delayed reveal (~350 ms) keeps
// the card out of the way during fast traversal; it dismisses on
// any click, right-click, scroll, or row change.
//
// Singleton DOM element appended to <body>; positioned fixed.
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
var SHOW_DELAY_MS = 350;
// Grace period after the cursor leaves the row before the card
// hides. Lets the user move INTO the card to select / copy text;
// the card cancels this timer on mouseenter.
var HIDE_DELAY_MS = 200;
var state = window.app.state;
var card = null;
var showTimer = null;
var hideTimer = null;
var currentRow = null;
function ensureCard() {
if (card) return card;
card = document.createElement('div');
card.className = 'tree-hovercard';
card.setAttribute('aria-hidden', 'true');
// Mouse interaction inside the card: cancel any pending hide
// so the user can stay in it as long as they want, then re-
// schedule on leave. Pointer-events:auto in the CSS lets the
// mouse enter; user-select:text (default) lets them drag a
// selection; right-click inside fires the browser's native
// Copy menu since we never call preventDefault for it here.
card.addEventListener('mouseenter', cancelHide);
card.addEventListener('mouseleave', scheduleHide);
document.body.appendChild(card);
return card;
}
function cancelHide() {
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
}
function scheduleHide() {
cancelHide();
hideTimer = setTimeout(hide, HIDE_DELAY_MS);
}
function hide() {
if (showTimer) { clearTimeout(showTimer); showTimer = null; }
cancelHide();
if (card) card.classList.remove('is-visible');
currentRow = null;
}
// ── Formatting (kept local so this module is self-contained) ──
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function fmtSize(bytes) {
if (bytes == null) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function fmtDate(d) {
if (!d) return '';
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function typeLabelFor(node) {
if (node.isDir) return 'Folder';
if (node.isZip) return 'Zip archive';
if (node.ext) return node.ext.toUpperCase() + ' file';
return 'File';
}
function buildRowsHtml(node) {
var tree = window.app.modules.tree;
var z = window.zddc;
var parsed = null;
if (z) {
parsed = node.isDir
? z.parseFolder(node.name)
: z.parseFilename(node.name);
}
var html = '';
// ZDDC fields first when the basename parses.
if (parsed && parsed.valid) {
if (parsed.date) html += kv('Date', parsed.date, true);
if (parsed.trackingNumber) html += kv('Tracking number', parsed.trackingNumber, true);
if (parsed.revision) html += kv('Revision', parsed.revision, true);
if (parsed.status) html += kv('Status', parsed.status, true);
if (parsed.title) html += kv('Title', parsed.title);
// Archive references — the /<project>/.archive/<tracking>.html
// URL is the latest issued version (highest base rev), and
// /<project>/.archive/<tracking>_<rev>.html pins the exact
// revision the user is currently hovering. The dispatcher
// canonicalises both forms to project-root so links work
// from any depth.
if (parsed.trackingNumber) {
var fullPath = tree ? tree.pathFor(node) : '';
var rel = fullPath.replace(/^\/+|\/+$/g, '');
var firstSeg = rel ? rel.split('/')[0] : '';
if (firstSeg) {
var encProject = encodeURIComponent(firstSeg);
var encTracking = encodeURIComponent(parsed.trackingNumber);
var latestUrl = '/' + encProject + '/.archive/' + encTracking + '.html';
var latestLbl = '.archive/' + parsed.trackingNumber + '.html';
html += kvLink('Latest', latestUrl, latestLbl);
if (!node.isDir && parsed.revision) {
var encRev = encodeURIComponent(parsed.revision);
var inspectUrl = '/' + encProject + '/.archive/' + encTracking + '_' + encRev + '.html';
var inspectLbl = '.archive/' + parsed.trackingNumber + '_' + parsed.revision + '.html';
html += kvLink('This revision', inspectUrl, inspectLbl);
}
}
}
html += '<div class="tree-hovercard__sep"></div>';
} else if (node.displayName) {
// Operator-supplied display name — only useful as info if
// it differs from the on-disk name.
html += kv('Display name', node.displayName);
}
html += kv('Type', typeLabelFor(node));
if (!node.isDir) html += kv('Filename', node.name, true);
if (!node.isDir && node.size != null) html += kv('Size', fmtSize(node.size));
if (node.modTime) html += kv('Modified', fmtDate(node.modTime));
if (node.virtual) html += kv('Virtual', 'Not yet created on disk');
// Path comes last (longest, most likely to wrap).
var path = tree ? tree.pathFor(node) : '';
if (path) html += kv('Path', path, true);
if (node.url && node.url !== path) html += kv('URL', node.url, true);
return html;
}
function kv(key, val, mono) {
return '<span class="tree-hovercard__key">' + escapeHtml(key) + '</span>'
+ '<span class="tree-hovercard__val'
+ (mono ? ' tree-hovercard__val--mono' : '')
+ '">' + escapeHtml(val) + '</span>';
}
// kvLink — value rendered as an <a> the user can click (opens in
// a new tab so the hover context isn't lost) or right-click to
// copy. Used for the .archive references on ZDDC files.
function kvLink(key, href, label) {
return '<span class="tree-hovercard__key">' + escapeHtml(key) + '</span>'
+ '<span class="tree-hovercard__val tree-hovercard__val--mono">'
+ '<a href="' + escapeHtml(href) + '" target="_blank" rel="noopener">'
+ escapeHtml(label)
+ '</a>'
+ '</span>';
}
function render(node) {
var z = window.zddc;
var parsed = z
? (node.isDir ? z.parseFolder(node.name) : z.parseFilename(node.name))
: null;
var primary, secondary = '';
if (parsed && parsed.valid) {
primary = parsed.title;
var parts = node.isDir
? [parsed.date, parsed.trackingNumber, parsed.status]
: [parsed.trackingNumber, parsed.revision, parsed.status];
secondary = parts.filter(Boolean).join(' · ');
} else if (node.displayName) {
primary = node.displayName;
} else {
primary = node.name;
}
card.innerHTML = ''
+ '<div class="tree-hovercard__header">'
+ '<div class="tree-hovercard__title">' + escapeHtml(primary) + '</div>'
+ (secondary
? '<div class="tree-hovercard__sub">' + escapeHtml(secondary) + '</div>'
: '')
+ '</div>'
+ '<div class="tree-hovercard__list">' + buildRowsHtml(node) + '</div>';
}
function position(row) {
// Two-pass measure: temporarily make visible-but-invisible so
// we can read offsetWidth / offsetHeight, compute placement,
// then reveal at the final coordinates.
card.style.left = '0px';
card.style.top = '0px';
card.style.visibility = 'hidden';
card.classList.add('is-visible');
var cw = card.offsetWidth;
var ch = card.offsetHeight;
var rect = row.getBoundingClientRect();
var GAP = 8;
var x = rect.right + GAP;
if (x + cw > window.innerWidth - GAP) {
x = rect.left - cw - GAP;
}
if (x < GAP) {
// Fallback: anchor under the row (last resort when the
// pane is wide enough that neither side fits).
x = Math.max(GAP, Math.min(rect.left, window.innerWidth - cw - GAP));
}
var y = rect.top;
if (y + ch > window.innerHeight - GAP) {
y = Math.max(GAP, window.innerHeight - ch - GAP);
}
if (y < GAP) y = GAP;
card.style.left = x + 'px';
card.style.top = y + 'px';
card.style.visibility = '';
}
function showFor(row, node) {
ensureCard();
render(node);
position(row);
card.classList.add('is-visible');
}
function init() {
var treeBody = document.getElementById('treeBody');
if (!treeBody) return;
treeBody.addEventListener('mouseover', function (e) {
// Returning to the tree from the card cancels any pending
// hide; the show logic below handles row changes.
cancelHide();
var row = e.target.closest('.tree-row');
if (row === currentRow) return;
// Row → row or row → empty space — reset.
if (showTimer) { clearTimeout(showTimer); showTimer = null; }
if (card) card.classList.remove('is-visible');
currentRow = row || null;
if (!row) return;
showTimer = setTimeout(function () {
if (currentRow !== row) return;
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (node) showFor(row, node);
}, SHOW_DELAY_MS);
});
// Leaving the tree schedules a hide rather than hiding
// immediately, so the cursor has time to traverse the gap to
// the card. The card's own mouseenter cancels the hide.
treeBody.addEventListener('mouseleave', scheduleHide);
treeBody.addEventListener('contextmenu', hide);
window.addEventListener('scroll', hide, true);
window.addEventListener('resize', hide);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') hide();
});
// Click anywhere outside the card dismisses it. Clicks INSIDE
// the card are allowed through so the user can drag-select
// text, right-click for the browser's native Copy menu, or
// hit Ctrl/Cmd-C.
document.addEventListener('mousedown', function (e) {
if (!card || !card.classList.contains('is-visible')) return;
if (card.contains(e.target)) return;
hide();
}, true);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.app.modules.hovercard = { hide: hide };
})();

View file

@ -8,6 +8,15 @@
window.app = { modules: {}, state: {} };
}
// Mount the shared Lucide outline-icon sprite into <body> before
// the tree first renders. The sprite is hidden (display:none on
// the outer <svg>) — it only exists so per-row <use href="#…"/>
// refs resolve. Falls back to deferring until DOMContentLoaded
// when <body> isn't ready yet.
if (window.zddc && window.zddc.icons) {
window.zddc.icons.inject();
}
window.app.state = {
// Source: 'server' | 'fs' | null. Determines how the loader
// resolves entries.
@ -61,6 +70,13 @@
// scopeDefaultTool: cascade's default_tool at currentPath
// (empty when no default declared)
scopeDropTarget: false,
scopeDefaultTool: ''
scopeDefaultTool: '',
// Autofilter — when non-empty, the tree hides files that
// don't match and folders whose subtree has no matches.
// Parsed once on input change so visibleIds() / rowHtml()
// can run filter.matches(text, ast) cheaply per node.
filterText: '',
filterAST: null
};
})();

View file

@ -37,6 +37,11 @@
modTime: e.mod_time ? new Date(e.mod_time) : null,
ext: e.is_dir ? '' : splitExt(name),
url: e.url || null,
// Server-computed write authority — true if the policy
// decider would allow a PUT for the calling principal.
// Absent / false means "save will 403"; preview editors
// read this to mount in read-only mode.
writable: !!e.writable,
// FS-API specific (null in server mode):
handle: null
};
@ -107,6 +112,20 @@
// without re-implementing the cascade client-side.
window.app.state.scopeDefaultTool =
(resp.headers.get('X-ZDDC-Default-Tool') || '').toLowerCase();
// X-ZDDC-On-Plan-Review surfaces whether the cascade above
// this path has an on_plan_review block. Drives visibility of
// the "Plan Review" right-click menu item on received/<tracking>/
// folders.
window.app.state.scopeOnPlanReview =
(resp.headers.get('X-ZDDC-On-Plan-Review') || '').toLowerCase() === 'true';
// X-ZDDC-Canonical-Folder names the canonical project-layout
// slot this directory occupies — "incoming", "received",
// "working", "staging", etc. Drives scope-aware menu items:
// Accept Transmittal (folders under incoming), Stage/Unstage
// (files under working/staging), Create Transmittal folder
// (right-click in staging).
window.app.state.scopeCanonicalFolder =
(resp.headers.get('X-ZDDC-Canonical-Folder') || '').toLowerCase();
if (resp.status === 404) {
return [];
}

276
browse/js/plan-review.js Normal file
View file

@ -0,0 +1,276 @@
// plan-review.js — the doc-controller "Plan Review" workflow modal.
//
// Surfaced by events.js as a right-click menu item on
// archive/<party>/received/<tracking>/ folders when the cascade above
// has an on_plan_review block (X-ZDDC-On-Plan-Review header on the
// listing).
//
// The modal collects four fields:
//
// - review_lead (becomes sub-admin of reviewing/<…>/)
// - plan_review_complete_date (the committed review-done date)
// - approver (becomes sub-admin of staging/<…>/)
// - plan_response_date (the committed response-issue date)
//
// The planned dates are immutable from the sub-admins' perspective —
// they live in the canonical submittal's .zddc
// (received/<tracking>/.zddc) where only the doc controller (via Plan
// Review re-run) can change them. The workflow folders' .zddc files
// carry only the back-link + per-folder ACL.
//
// Title is auto-derived server-side from the first ZDDC-parseable
// file in received/<tracking>/. Forecast dates default to the planned
// dates at scaffolding time; the user renames the workflow folder
// directly to update the forecast later.
//
// On submit, the form assembles a YAML body and POSTs it with
// X-ZDDC-Op: plan-review to the received/<tracking>/ URL.
(function () {
'use strict';
var REVIEW_OFFSET_DAYS = 7;
var RESPONSE_OFFSET_DAYS = 14;
function statusInfo(msg) {
var el = document.getElementById('statusBar');
if (!el) return;
el.textContent = msg || '';
el.classList.remove('status-bar--error');
el.classList.add('status-bar--info');
}
function statusError(msg) {
var el = document.getElementById('statusBar');
if (!el) return;
el.textContent = msg || '';
el.classList.remove('status-bar--info');
el.classList.add('status-bar--error');
}
// Compute today + N days as a YYYY-MM-DD string.
function isoDatePlus(days) {
var d = new Date();
d.setDate(d.getDate() + days);
var y = d.getFullYear();
var m = ('0' + (d.getMonth() + 1)).slice(-2);
var dd = ('0' + d.getDate()).slice(-2);
return y + '-' + m + '-' + dd;
}
// Fetch suggestion emails from /.profile/access so the originator
// field has a datalist of likely values. Best-effort — silent on
// failure (the field still accepts free text).
async function fetchOriginatorSuggestions() {
try {
var resp = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!resp.ok) return [];
var data = await resp.json();
var out = [];
// The endpoint exposes the current user + any role members
// visible to them. Pull anything that looks like an email
// for the datalist; the field is otherwise free text.
if (data && data.email) out.push(data.email);
return out;
} catch (_e) {
return [];
}
}
// Build the YAML body for the plan-review POST. Quoting is minimal
// (just enough for emails with special chars).
function buildBody(values) {
function yamlString(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
return [
'review_lead: ' + yamlString(values.reviewLead),
'approver: ' + yamlString(values.approver),
'plan_review_complete_date: ' + values.planReviewDate,
'plan_response_date: ' + values.planResponseDate,
''
].join('\n');
}
// Render the modal. Returns a Promise that resolves on submit
// (with the collected values) or rejects on cancel.
function openForm(initial) {
return new Promise(function (resolve, reject) {
var overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div');
box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:24rem;max-width:32rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);font-family:inherit;';
box.innerHTML =
'<h2 style="margin:0 0 0.75rem 0;font-size:1.1rem;">Plan Review — ' + escapeHtml(initial.tracking) + '</h2>' +
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.5rem 0.75rem;align-items:center;font-size:0.9rem;">' +
'<label for="pr-review-lead">Review lead</label>' +
'<input id="pr-review-lead" type="email" list="pr-people-list" required style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of reviewing/<…>">' +
'<label for="pr-review-date">Plan review complete date</label>' +
'<input id="pr-review-date" type="date" required>' +
'<label for="pr-approver">Approver</label>' +
'<input id="pr-approver" type="email" list="pr-people-list" required style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of staging/<…>">' +
'<label for="pr-response-date">Plan response date</label>' +
'<input id="pr-response-date" type="date" required>' +
'<datalist id="pr-people-list"></datalist>' +
'</div>' +
'<p style="margin:0.75rem 0 0 0;font-size:0.8rem;color:#666;">Planned dates seal at first submission — they become part of the canonical record (received/<tracking>/.zddc) and the WORM zone prevents further edits. Subsequent Plan Reviews can swap the review lead or approver without changing the dates.</p>' +
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
'<button type="button" id="pr-cancel">Cancel</button>' +
'<button type="button" id="pr-submit" class="btn-primary">Plan Review</button>' +
'</div>';
overlay.appendChild(box);
document.body.appendChild(overlay);
var reviewLeadInput = box.querySelector('#pr-review-lead');
var approverInput = box.querySelector('#pr-approver');
var reviewDateInput = box.querySelector('#pr-review-date');
var responseDateInput = box.querySelector('#pr-response-date');
reviewDateInput.value = isoDatePlus(REVIEW_OFFSET_DAYS);
responseDateInput.value = isoDatePlus(RESPONSE_OFFSET_DAYS);
// Populate the datalist with people suggestions (best
// effort — silent on failure).
fetchOriginatorSuggestions().then(function (emails) {
var dl = box.querySelector('#pr-people-list');
if (!dl) return;
emails.forEach(function (e) {
var opt = document.createElement('option');
opt.value = e;
dl.appendChild(opt);
});
});
function close() {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
}
box.querySelector('#pr-cancel').addEventListener('click', function () {
close();
reject(new Error('cancelled'));
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay) {
close();
reject(new Error('cancelled'));
}
});
document.addEventListener('keydown', function escHandler(e) {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
close();
reject(new Error('cancelled'));
}
});
box.querySelector('#pr-submit').addEventListener('click', function () {
var values = {
reviewLead: reviewLeadInput.value.trim(),
approver: approverInput.value.trim(),
planReviewDate: reviewDateInput.value,
planResponseDate: responseDateInput.value
};
if (!values.reviewLead || !values.approver
|| !values.planReviewDate || !values.planResponseDate) {
statusError('All fields are required.');
return;
}
close();
resolve(values);
});
reviewLeadInput.focus();
});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
})[c];
});
}
// Detect whether a tree node is an archive/<party>/received/<tracking>/
// folder. The path is path-shaped, not content-based — tracking-number
// content is not inspected (per design).
function isReceivedTrackingFolder(node) {
if (!node || !node.isDir) return false;
var tree = window.app.modules.tree;
if (!tree) return false;
var p = tree.pathFor(node).replace(/\/$/, '');
var rel = p.replace(/^\/+/, '');
var parts = rel.split('/');
return parts.length === 5
&& parts[1].toLowerCase() === 'archive'
&& parts[3].toLowerCase() === 'received';
}
// Run the Plan Review flow: open the modal, POST the result.
async function invoke(node) {
var tree = window.app.modules.tree;
if (!tree) return;
var url = tree.pathFor(node);
if (!url.endsWith('/')) url += '/';
var parts = url.replace(/^\/+/, '').replace(/\/$/, '').split('/');
var tracking = parts[parts.length - 1];
var values;
try {
values = await openForm({ tracking: tracking });
} catch (_e) {
return; // cancelled
}
statusInfo('Plan Review — submitting…');
var body = buildBody(values);
var resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: {
'X-ZDDC-Op': 'plan-review',
'Content-Type': 'application/yaml'
},
body: body,
credentials: 'same-origin'
});
} catch (e) {
statusError('Plan Review failed: ' + (e && e.message ? e.message : e));
return;
}
if (!resp.ok) {
var text = '';
try { text = await resp.text(); } catch (_e) { /* ignore */ }
statusError('Plan Review failed (' + resp.status + '): ' + text);
return;
}
var data;
try { data = await resp.json(); } catch (_e) { data = null; }
if (data && data.reviewing && data.staging) {
var rPart = data.reviewing.created ? 'created' : 'updated';
var sPart = data.staging.created ? 'created' : 'updated';
var seal = (data.received && data.received.created)
? ' Canonical record sealed.'
: (data.received && !data.received.zddc_written)
? ' Canonical dates left untouched (already sealed).'
: '';
statusInfo('Plan Review: reviewing ' + rPart + ', staging ' + sPart + '.' + seal +
' Reload the relevant folder to see the new entries.');
} else {
statusInfo('Plan Review complete.');
}
}
window.app.modules.planReview = {
isReceivedTrackingFolder: isReceivedTrackingFolder,
invoke: invoke
};
})();

View file

@ -304,6 +304,11 @@
function canSave(node) {
if (isZipMemberNode(node)) return false;
// Server-computed authority gate. The listing's `writable`
// bit reflects what a PUT would do — false here means the
// file API would 403 the save, so we mount in read-only
// mode rather than letting the user type and lose changes.
if (node.url && window.app.state.source === 'server' && !node.writable) return false;
if (node.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true;
return false;
@ -346,13 +351,18 @@
container.appendChild(shell);
// ── Sidebar (col 1): front matter (top) + TOC (bottom) ──────────────
// Sidebar is a flex column: FM section (fixed height, set
// inline below) + horizontal resizer + TOC section (1fr).
var sidebar = document.createElement('div');
sidebar.className = 'md-shell__sidebar';
sidebar.style.gridTemplateRows = lastFmHeight + 'px 1fr';
shell.appendChild(sidebar);
var fmSection = document.createElement('section');
fmSection.className = 'md-side md-side--fm';
// Front-matter height is driven inline (persisted across
// remounts via lastFmHeight) so the resizer's drag-handler
// mutates a single source of truth.
fmSection.style.height = lastFmHeight + 'px';
var fmHeader = document.createElement('div');
fmHeader.className = 'md-side__header';
fmHeader.textContent = 'YAML front matter';
@ -363,7 +373,10 @@
fmTextarea.spellcheck = false;
fmTextarea.autocapitalize = 'off';
fmTextarea.autocomplete = 'off';
fmTextarea.placeholder = 'title: Document Title\ndate: 2026-05-13\ntags: [example]';
// No placeholder text — files with no YAML front matter render
// as a genuinely empty pane. Showing a synthetic example would
// make the file look like it had data when it doesn't.
fmTextarea.placeholder = '';
fmBody.appendChild(fmTextarea);
fmSection.appendChild(fmHeader);
fmSection.appendChild(fmBody);
@ -452,10 +465,16 @@
node.url && /\.md$/i.test(node.name);
var convertBtns = [];
if (serverModeMd) {
// Virtual-extension URLs: <file>.md → <file>.docx etc.
// The dispatcher recognises the sibling-extension pattern
// and routes through ServeConverted. Cleaner than the
// old `?convert=` query form — right-clicking the link
// gives a sensible "Save as <file>.docx" prompt.
var mdUrlBase = node.url.replace(/\.md$/i, '');
['docx', 'html', 'pdf'].forEach(function (fmt) {
var a = document.createElement('a');
a.className = 'btn btn-sm btn-secondary md-shell__download';
a.href = node.url + '?convert=' + encodeURIComponent(fmt);
a.href = mdUrlBase + '.' + fmt;
// target=_blank: clicks open in a new tab. The server
// sends Content-Disposition: inline, so the new tab
// either renders (HTML → web page; PDF → browser's
@ -499,21 +518,44 @@
var bodyText = initialParsed.body;
var initialHash = await hashContent(assembleContent(fmTextarea.value, bodyText));
var editor = new window.toastui.Editor({
var writableMode = canSave(node);
// autofocus:false keeps the keyboard caret in the tree pane —
// arrow-key nav can continue through markdown files without
// diverting into the editor. The user clicks into the editor
// (or tabs to it) when they actually want to type.
var editorOpts = {
el: editorHost,
height: '100%',
initialEditType: 'markdown',
previewStyle: 'vertical',
initialValue: bodyText,
usageStatistics: false,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task', 'indent', 'outdent'],
['table', 'image', 'link'],
['code', 'codeblock']
]
});
autofocus: false,
initialValue: bodyText,
};
var editor;
if (!writableMode) {
// Read-only mount uses Toast UI's Viewer (rendered markdown,
// no edit toolbar, no caret). The disabled Save button +
// its tooltip carry the read-only signal — no banner here
// since the Viewer's lack of edit chrome is already a
// clear visual cue.
editor = window.toastui.Editor.factory(Object.assign({}, editorOpts, {
viewer: true,
}));
} else {
editor = new window.toastui.Editor(Object.assign({}, editorOpts, {
// WYSIWYG by default — most users want the rendered view
// out of the gate; the markdown/WYSIWYG toggle in the
// Toast UI toolbar still flips to source mode in one click.
initialEditType: 'wysiwyg',
previewStyle: 'vertical',
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task', 'indent', 'outdent'],
['table', 'image', 'link'],
['code', 'codeblock']
]
}));
}
currentInstance = {
editor: editor,
@ -525,8 +567,7 @@
fmEl: fmTextarea
};
var writable = canSave(node);
if (!writable) {
if (!writableMode) {
saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.';
fmTextarea.readOnly = true;
@ -592,7 +633,7 @@
var dy = e.clientY - startY;
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), startH + dy));
lastFmHeight = h;
sidebar.style.gridTemplateRows = h + 'px 1fr';
fmSection.style.height = h + 'px';
e.preventDefault();
}
function onUp() {
@ -616,14 +657,17 @@
var step = e.key === 'ArrowUp' ? -24 : 24;
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), lastFmHeight + step));
lastFmHeight = h;
sidebar.style.gridTemplateRows = h + 'px 1fr';
fmSection.style.height = h + 'px';
});
})();
// ── Change tracking + auto-rerender ────────────────────────────────
function markDirty(isDirty) {
currentInstance.dirty = isDirty;
saveBtn.disabled = !isDirty || !writable;
// Re-read canSave at every transition, not via a closure-captured
// value, so the gate reflects current write authority — see the
// matching pattern in preview-yaml.js.
saveBtn.disabled = !isDirty || !canSave(node);
dirtyEl.textContent = isDirty ? '● modified' : '';
}
@ -644,7 +688,7 @@
// ── Save ───────────────────────────────────────────────────────────
async function save() {
if (!currentInstance.dirty || !writable) return;
if (!currentInstance.dirty || !canSave(node)) return;
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
try {
statusEl.textContent = 'Saving…';
@ -690,7 +734,7 @@
}
// Dirty: intercept, save, retry.
e.preventDefault();
if (!writable) {
if (!canSave(node)) {
if (window.zddc && window.zddc.toast) {
window.zddc.toast(
'This source is read-only — save a copy elsewhere first.',

559
browse/js/preview-yaml.js Normal file
View file

@ -0,0 +1,559 @@
// preview-yaml.js — YAML editor plugin for the browse preview pane.
//
// Routes any .yaml / .yml file, plus the .zddc cascade files
// (`.zddc` and `*.zddc.yaml`), through a CodeMirror 5 editor with
// syntax highlighting and live linting. js-yaml.loadAll feeds parse
// errors into CM's lint gutter; for .zddc files an additional
// schema-aware pass flags unknown keys, bad enum values, and wrong
// types.
//
// Layout (single column):
// ┌─────────────────────────────────────────────────────────────┐
// │ name | dirty | status | source | [Save] │
// ├─────────────────────────────────────────────────────────────┤
// │ CodeMirror editor (line numbers + lint gutter) │
// └─────────────────────────────────────────────────────────────┘
//
// Save (Ctrl+S) writes back via PUT (server mode) or
// FileSystemWritableFileStream (FS-API). Zip members and
// virtual nodes are read-only — Save stays disabled.
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Filename routing ────────────────────────────────────────────────────
// True for .zddc cascade files — `.zddc` (literal name, no ext)
// and `<anything>.zddc.yaml` (e.g. `defaults.zddc.yaml`). These
// get the schema-aware lint layer.
function isZddcFile(name) {
if (!name) return false;
if (name === '.zddc') return true;
return /\.zddc\.ya?ml$/i.test(name);
}
function isYamlFile(node) {
if (!node || !node.name) return false;
if (isZddcFile(node.name)) return true;
var ext = (node.ext || '').toLowerCase();
return ext === 'yaml' || ext === 'yml';
}
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
async function saveContent(node, content) {
if (node.handle && typeof node.handle.createWritable === 'function') {
var writable = await node.handle.createWritable();
await writable.write(content);
await writable.close();
return;
}
if (node.url && window.app.state.source === 'server') {
var resp = await fetch(node.url, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-yaml; charset=utf-8' },
body: content,
credentials: 'same-origin'
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return;
}
throw new Error('No write target for this file (read-only source).');
}
function isZipMemberNode(node) {
if (node.handle && node.handle.isZipEntry) return true;
if (node.url && window.app.state.source === 'server'
&& /\.zip\//i.test(node.url)) return true;
return false;
}
function canSave(node) {
if (isZipMemberNode(node)) return false;
// Virtual .zddc placeholders are designed to be saved — a PUT
// materializes the file from the synthetic body and the next
// listing serves a real entry. Every other virtual node (per-
// user home, canonical-folder virtuals) is just a tree
// affordance, not a writable file.
if (node.virtual && node.name !== '.zddc') return false;
// Server-computed authority gate. Mirrors the markdown editor's
// check — listing's `writable` bit is the same decision the
// file API would reach on PUT.
if (node.url && window.app.state.source === 'server' && !node.writable) return false;
if (node.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true;
return false;
}
async function hashContent(text) {
if (!window.crypto || !window.crypto.subtle) return null;
var enc = new TextEncoder().encode(text);
var buf = await window.crypto.subtle.digest('SHA-256', enc);
var bytes = new Uint8Array(buf);
var hex = '';
for (var i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, '0');
}
return hex;
}
// ── .zddc schema ────────────────────────────────────────────────────────
//
// Mirrors the Go-side decoder in zddc/internal/zddc/*. Allowed
// tool names are the embedded set (always available) plus the
// composable ones served when declared in apps:. Unknown keys at
// any level surface as warnings — typos like `defaul_tool` are
// common and the cascade silently ignores them.
var ALLOWED_TOOLS = {
archive: 1, browse: 1, landing: 1, transmittal: 1, classifier: 1,
tables: 1, form: 1
};
var TOP_KEYS = {
title: 'string',
acl: 'acl',
admins: 'string[]',
roles: 'rolemap',
available_tools: 'tools[]',
default_tool: 'tool',
dir_tool: 'tool',
auto_own: 'bool',
auto_own_fenced: 'bool',
virtual: 'bool',
drop_target: 'bool',
worm: 'string[]',
paths: 'pathmap',
display: 'stringmap',
apps: 'appsmap',
apps_pubkey: 'string',
tables: 'stringmap',
convert: 'convert',
created_by: 'string',
inherit: 'bool'
};
var ACL_KEYS = { inherit: 'bool', permissions: 'stringmap',
allow: 'string[]', deny: 'string[]' };
var ROLE_KEYS = { members: 'string[]', reset: 'bool' };
var CONVERT_KEYS = { client: 'string', project: 'string',
contractor: 'string', project_number: 'string' };
function typeOf(v) {
if (v === null || v === undefined) return 'null';
if (Array.isArray(v)) return 'array';
return typeof v; // 'string' | 'number' | 'boolean' | 'object'
}
// Collect schema issues for a parsed .zddc document. Each issue is
// { keyPath: string[], message: string, severity: 'error' | 'warning' }.
// keyPath is used by findLine() to locate the offending source line.
function validateZddc(doc) {
var issues = [];
if (typeOf(doc) === 'null') return issues;
if (typeOf(doc) !== 'object') {
issues.push({ keyPath: [], severity: 'error',
message: 'Root must be a map (got ' + typeOf(doc) + ').' });
return issues;
}
walkObject(doc, TOP_KEYS, [], issues);
return issues;
}
function walkObject(obj, schema, path, issues) {
for (var key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
var here = path.concat([key]);
var kind = schema[key];
if (!kind) {
issues.push({ keyPath: here, severity: 'warning',
message: 'Unknown key "' + key + '" — typo? It will be silently ignored.' });
continue;
}
checkValue(obj[key], kind, here, issues);
}
}
function checkValue(val, kind, path, issues) {
var t = typeOf(val);
switch (kind) {
case 'string':
if (t !== 'string' && t !== 'null') addTypeErr(path, kind, t, issues);
return;
case 'bool':
if (t !== 'boolean' && t !== 'null') addTypeErr(path, kind, t, issues);
return;
case 'string[]':
if (t !== 'array' && t !== 'null') addTypeErr(path, kind, t, issues);
return;
case 'tools[]':
if (t !== 'array' && t !== 'null') {
addTypeErr(path, kind, t, issues); return;
}
if (t === 'array') {
for (var i = 0; i < val.length; i++) {
if (typeOf(val[i]) !== 'string') {
issues.push({ keyPath: path, severity: 'error',
message: 'available_tools[' + i + '] must be a string.' });
} else if (!ALLOWED_TOOLS[val[i]]) {
issues.push({ keyPath: path, severity: 'warning',
message: 'Unknown tool "' + val[i]
+ '". Known: ' + Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
}
}
}
return;
case 'tool':
if (t === 'null') return;
if (t !== 'string') { addTypeErr(path, kind, t, issues); return; }
if (!ALLOWED_TOOLS[val]) {
issues.push({ keyPath: path, severity: 'warning',
message: 'Unknown tool "' + val + '". Known: '
+ Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
}
return;
case 'stringmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var k in val) {
if (!Object.prototype.hasOwnProperty.call(val, k)) continue;
if (typeOf(val[k]) !== 'string') {
issues.push({ keyPath: path.concat([k]), severity: 'error',
message: 'Value must be a string (got '
+ typeOf(val[k]) + ').' });
}
}
return;
case 'pathmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var seg in val) {
if (!Object.prototype.hasOwnProperty.call(val, seg)) continue;
if (seg.indexOf('/') !== -1) {
issues.push({ keyPath: path.concat([seg]), severity: 'error',
message: 'Path keys must be a single segment — '
+ 'nest blocks instead of using "' + seg + '".' });
}
var v = val[seg];
if (typeOf(v) === 'null') continue;
if (typeOf(v) !== 'object') {
issues.push({ keyPath: path.concat([seg]), severity: 'error',
message: 'paths.' + seg + ' must be a map of cascade rules.' });
continue;
}
walkObject(v, TOP_KEYS, path.concat([seg]), issues);
}
return;
case 'appsmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var app in val) {
if (!Object.prototype.hasOwnProperty.call(val, app)) continue;
if (!ALLOWED_TOOLS[app]) {
issues.push({ keyPath: path.concat([app]), severity: 'warning',
message: 'Unknown tool "' + app + '" in apps:.' });
}
if (typeOf(val[app]) !== 'string') {
issues.push({ keyPath: path.concat([app]), severity: 'error',
message: 'apps.' + app + ' must be a spec string '
+ '(channel | v<semver> | URL | path).' });
}
}
return;
case 'rolemap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var rn in val) {
if (!Object.prototype.hasOwnProperty.call(val, rn)) continue;
var rv = val[rn];
if (typeOf(rv) === 'null') continue;
if (typeOf(rv) !== 'object') {
issues.push({ keyPath: path.concat([rn]), severity: 'error',
message: 'roles.' + rn + ' must be a map ({members, reset}).' });
continue;
}
walkObject(rv, ROLE_KEYS, path.concat([rn]), issues);
}
return;
case 'acl':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
walkObject(val, ACL_KEYS, path, issues);
return;
case 'convert':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
walkObject(val, CONVERT_KEYS, path, issues);
return;
}
}
function addTypeErr(path, expected, got, issues) {
issues.push({ keyPath: path, severity: 'error',
message: 'Expected ' + expected + ', got ' + got + '.' });
}
// Locate the source line for a key path. .zddc files are
// block-style YAML in practice (no flow style, no anchors), so a
// simple indent-aware scan works: for each segment, find a line
// matching "<indent><key>:" whose indent is deeper than the
// previously-matched line. Falls back to line 0 if no match.
function findLine(source, keyPath) {
if (!keyPath || keyPath.length === 0) return 0;
var lines = source.split('\n');
var prevIndent = -1;
var prevLine = 0;
for (var i = 0; i < keyPath.length; i++) {
var key = keyPath[i];
var found = -1;
// Escape regex metachars in the key.
var keyRe = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
var re = new RegExp('^(\\s*)"?' + keyRe + '"?\\s*:');
for (var j = prevLine; j < lines.length; j++) {
var m = lines[j].match(re);
if (m && m[1].length > prevIndent) {
found = j;
prevIndent = m[1].length;
prevLine = j + 1;
break;
}
}
if (found === -1) return prevLine > 0 ? prevLine - 1 : 0;
}
return prevLine > 0 ? prevLine - 1 : 0;
}
// ── CodeMirror lint helper ──────────────────────────────────────────────
function registerLinter(CM) {
// The lint helper signature: function(text, options, editor) → annotations[]
// Each annotation: { from, to, message, severity }.
CM.registerHelper('lint', 'yaml', function (text, _opts, editor) {
var out = [];
if (!window.jsyaml) return out;
var parsed;
try {
// loadAll handles multi-doc YAML; we only validate the
// first doc against the schema (the .zddc cascade reads
// only the first document).
var docs = [];
window.jsyaml.loadAll(text, function (d) { docs.push(d); });
parsed = docs[0];
} catch (e) {
var mark = e.mark;
var pos = mark ? CM.Pos(mark.line, mark.column) : CM.Pos(0, 0);
out.push({ from: pos, to: pos, severity: 'error',
message: e.message || String(e) });
return out;
}
// Schema layer — only for .zddc cascade files.
var node = editor._zddcNode;
if (node && isZddcFile(node.name)) {
var issues = validateZddc(parsed);
for (var i = 0; i < issues.length; i++) {
var ln = findLine(text, issues[i].keyPath);
out.push({
from: CM.Pos(ln, 0),
to: CM.Pos(ln, (editor.getLine(ln) || '').length),
severity: issues[i].severity,
message: issues[i].message
});
}
}
return out;
});
}
// ── Mount ───────────────────────────────────────────────────────────────
var currentEditor = null;
function dispose() {
// CM doesn't have an explicit destroy(); GC handles it once
// the host element is removed. Clear our reference so a stale
// editor doesn't keep handlers alive.
currentEditor = null;
}
async function render(node, container, ctx) {
if (typeof window.CodeMirror === 'undefined') {
container.innerHTML =
'<div class="preview-empty" style="color:var(--danger)">'
+ 'CodeMirror isn\'t bundled in this build.</div>';
return;
}
dispose();
var text;
try {
var buf = await ctx.getArrayBuffer(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
} catch (e) {
container.innerHTML =
'<div class="preview-empty" style="color:var(--danger)">'
+ 'Could not read ' + escapeHtml(node.name) + ': '
+ escapeHtml(e.message || String(e)) + '</div>';
return;
}
container.innerHTML = '';
var shell = document.createElement('div');
shell.className = 'yaml-shell';
container.appendChild(shell);
// Info header — same look as the markdown plugin's info-header
// so the two editors feel like one family.
var infohdr = document.createElement('div');
infohdr.className = 'md-shell__infohdr yaml-shell__infohdr';
var titleEl = document.createElement('span');
titleEl.className = 'md-shell__title';
titleEl.textContent = node.name;
titleEl.title = node.name;
var schemaTag = document.createElement('span');
schemaTag.className = 'md-shell__source yaml-shell__schema';
if (isZddcFile(node.name)) {
schemaTag.textContent = '.zddc schema';
schemaTag.title = 'Linted against the .zddc cascade schema '
+ '(unknown keys, bad enums, and wrong types are flagged).';
} else {
schemaTag.textContent = 'YAML';
}
var dirtyEl = document.createElement('span');
dirtyEl.className = 'md-shell__dirty';
var statusEl = document.createElement('span');
statusEl.className = 'md-shell__status';
var sourceEl = document.createElement('span');
sourceEl.className = 'md-shell__source';
if (isZipMemberNode(node)) sourceEl.textContent = 'read-only (zip)';
else if (node.handle) sourceEl.textContent = 'local';
else if (node.url) sourceEl.textContent = 'server';
var saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
saveBtn.type = 'button';
saveBtn.textContent = 'Save';
saveBtn.disabled = true;
infohdr.appendChild(titleEl);
infohdr.appendChild(schemaTag);
infohdr.appendChild(dirtyEl);
infohdr.appendChild(statusEl);
infohdr.appendChild(sourceEl);
infohdr.appendChild(saveBtn);
shell.appendChild(infohdr);
var editorHost = document.createElement('div');
editorHost.className = 'yaml-shell__editor';
shell.appendChild(editorHost);
// Register the lint helper once per page lifetime.
if (!window.CodeMirror.__zddcYamlLinterReady) {
registerLinter(window.CodeMirror);
window.CodeMirror.__zddcYamlLinterReady = true;
}
var writable = canSave(node);
var editor = window.CodeMirror(editorHost, {
value: text,
mode: 'yaml',
lineNumbers: true,
tabSize: 2,
indentUnit: 2,
indentWithTabs: false,
lineWrapping: false,
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
lint: { hasGutters: true },
// autofocus:false keeps the keyboard caret in the browse
// tree pane so arrow-key nav can continue through yaml /
// .zddc files without diverting into the editor. User
// clicks (or tabs) into the editor when they want to type.
autofocus: false,
// CodeMirror's "nocursor" mode is the truest read-only:
// selection allowed for copy, no caret, no edit affordances.
readOnly: !writable ? 'nocursor' : false,
});
// Stash the node on the editor so the lint helper can decide
// whether to apply the .zddc schema layer.
editor._zddcNode = node;
// Force an initial lint pass now that _zddcNode is set.
editor.performLint();
currentEditor = editor;
if (!writable) {
saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.';
// Read-only banner above the editor explains why.
var roBanner = document.createElement('div');
roBanner.className = 'yaml-readonly-banner';
roBanner.innerHTML = '<span aria-hidden="true">🔒</span>'
+ ' Read-only — you don\'t have write access to this file.';
editorHost.insertBefore(roBanner, editorHost.firstChild);
}
var initialHash = await hashContent(text);
function markDirty(isDirty) {
saveBtn.disabled = !isDirty || !canSave(node);
dirtyEl.textContent = isDirty ? '● modified' : '';
}
editor.on('change', async function () {
var h = await hashContent(editor.getValue());
markDirty(h !== initialHash);
});
async function save() {
if (saveBtn.disabled) return;
// Re-check authority at click time, not via the mount-time
// `writable` capture — the listing may have re-evaluated
// (e.g. user toggled admin mode without a hard reload).
if (!canSave(node)) return;
var content = editor.getValue();
try {
statusEl.textContent = 'Saving…';
await saveContent(node, content);
initialHash = await hashContent(content);
markDirty(false);
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success');
}
} catch (e) {
statusEl.textContent = 'Save failed: ' + (e.message || e);
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
}
}
}
saveBtn.addEventListener('click', save);
editor.setOption('extraKeys', {
'Ctrl-S': save,
'Cmd-S': save
});
// CM defers layout until its host has a size — refresh after
// mount so the gutters and viewport sync to the grid cell.
setTimeout(function () { try { editor.refresh(); } catch (_e) {} }, 0);
}
function handles(node) {
if (!node || node.isDir || node.isZip) return false;
return isYamlFile(node);
}
window.app.modules.yamledit = {
handles: handles,
render: render
};
})();

View file

@ -117,6 +117,19 @@
return;
}
// YAML plugin: .yaml / .yml / .zddc / *.zddc.yaml route to a
// CodeMirror 5 editor with js-yaml linting; .zddc files also
// get a schema-aware lint pass.
var yamlMod = window.app.modules.yamledit;
if (yamlMod && yamlMod.handles(node)) {
try {
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer });
} catch (e) {
renderError(container, 'YAML render failed: ' + (e.message || e));
}
return;
}
// PDF / HTML → iframe.
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
try {

329
browse/js/stage.js Normal file
View file

@ -0,0 +1,329 @@
// stage.js — Stage and Unstage workflow modals.
//
// Stage: move a file from working/<…>/ into a transmittal folder under
// staging/<…>/. Modal lists existing transmittal folders in staging/
// plus a "New transmittal folder…" option that prompts for a ZDDC-
// conforming name and mkdirs it before the move.
//
// Unstage: move a file from staging/<transmittal>/ back to the user's
// working/<email>/ home (overridable).
//
// Both reuse the existing X-ZDDC-Op: move primitive — no new composite
// endpoint is needed; the client just orchestrates one POST per file
// (a multi-file selection iterates and reports aggregate status).
(function () {
'use strict';
function status(msg, level) {
var t = window.zddc && window.zddc.toast;
if (t) t(msg, level || 'info');
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c];
});
}
// ── Scope detection: path-shape, not cascade-content ──────────────
// A file is stageable if its containing folder lives under
// /<project>/working/<…>. Unstageable if it lives under
// /<project>/staging/<transmittal>/<…>. Both are path-shape
// queries — content/ACL is enforced server-side.
function projectAndSubtree(path) {
var rel = path.replace(/^\/+|\/+$/g, '').split('/');
if (rel.length < 2) return null;
return { project: rel[0], subtree: rel[1], rest: rel.slice(2) };
}
function isStageableFile(node) {
if (!node || node.isDir || node.virtual) return false;
var tree = window.app.modules.tree;
if (!tree) return false;
var p = projectAndSubtree(tree.pathFor(node));
return !!(p && p.subtree === 'working' && p.rest.length >= 1);
}
function isUnstageableFile(node) {
if (!node || node.isDir || node.virtual) return false;
var tree = window.app.modules.tree;
if (!tree) return false;
var p = projectAndSubtree(tree.pathFor(node));
// staging/<transmittal-folder>/<file> — at least one folder
// segment between staging/ and the file.
return !!(p && p.subtree === 'staging' && p.rest.length >= 2);
}
// ── Server helpers ─────────────────────────────────────────────────
// Fetch directory listing JSON. Returns [] on 404.
async function listDir(absUrl) {
if (!absUrl.endsWith('/')) absUrl += '/';
var resp = await fetch(absUrl, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (resp.status === 404) return [];
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + absUrl);
var data = await resp.json();
return Array.isArray(data) ? data : [];
}
async function fetchStagingFolders(project) {
var entries = await listDir('/' + project + '/staging/');
return entries
.filter(function (e) { return e && e.isDir; })
.map(function (e) { return e.name; });
}
async function fetchSelfEmail() {
try {
var r = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!r.ok) return '';
var d = await r.json();
return (d && d.email) || '';
} catch (_e) { return ''; }
}
// POST X-ZDDC-Op: mkdir to create a new directory. Idempotent.
async function mkdir(absUrl) {
var resp = await fetch(absUrl, {
method: 'POST',
headers: { 'X-ZDDC-Op': 'mkdir' },
credentials: 'same-origin'
});
if (!resp.ok) {
var text = ''; try { text = await resp.text(); } catch (_e) {}
throw new Error('mkdir ' + absUrl + ' failed (' + resp.status + '): ' + text);
}
}
// POST X-ZDDC-Op: move + X-ZDDC-Destination header. Reuses the
// file-API move primitive (atomic os.Rename, dual ACL gates).
async function moveFile(srcUrl, dstUrl) {
var resp = await fetch(srcUrl, {
method: 'POST',
headers: {
'X-ZDDC-Op': 'move',
'X-ZDDC-Destination': dstUrl
},
credentials: 'same-origin'
});
if (!resp.ok) {
var text = ''; try { text = await resp.text(); } catch (_e) {}
throw new Error('move ' + srcUrl + ' → ' + dstUrl + ' failed (' + resp.status + '): ' + text);
}
}
// ── Stage picker modal ─────────────────────────────────────────────
function openStagePicker(initial) {
return new Promise(function (resolve, reject) {
var overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div');
box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);';
var folderList = initial.folders.map(function (name) {
return '<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;">' +
'<input type="radio" name="stage-target" value="' + escapeHtml(name) + '">' +
'<span style="font-family:var(--code,monospace);">' + escapeHtml(name) + '</span>' +
'</label>';
}).join('');
box.innerHTML =
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Stage ' +
initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + ' to…</h2>' +
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
'Pick the transmittal folder in <code>staging/</code> these files should join. ' +
'You can move them back to <code>working/</code> later if they need correction.' +
'</p>' +
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
(folderList || '<em style="color:#888;">No existing transmittal folders in staging/.</em>') +
'<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' +
'<input type="radio" name="stage-target" value="__new__">' +
'<span><strong>New transmittal folder…</strong></span>' +
'</label>' +
'</div>' +
'<div id="stage-newname-row" style="display:none;font-size:0.9rem;">' +
'<label for="stage-newname">Folder name (ZDDC convention)</label><br>' +
'<input id="stage-newname" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" ' +
'placeholder="YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT">' +
'<div id="stage-newname-feedback" style="font-size:0.8rem;color:#888;margin-top:0.2rem;"></div>' +
'</div>' +
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
'<button type="button" id="stage-cancel">Cancel</button>' +
'<button type="button" id="stage-submit" class="btn-primary">Stage</button>' +
'</div>';
overlay.appendChild(box);
document.body.appendChild(overlay);
var newRow = box.querySelector('#stage-newname-row');
var newInput = box.querySelector('#stage-newname');
var feedback = box.querySelector('#stage-newname-feedback');
box.querySelectorAll('input[name="stage-target"]').forEach(function (r) {
r.addEventListener('change', function () {
newRow.style.display = (r.value === '__new__' && r.checked) ? '' : 'none';
if (r.value === '__new__' && r.checked) newInput.focus();
});
});
newInput.addEventListener('input', function () {
var v = newInput.value.trim();
if (!v) { feedback.textContent = ''; return; }
var parsed = window.zddc.parseFolder(v);
if (parsed && parsed.valid) {
feedback.style.color = '#2a8';
feedback.textContent = '✓ tracking=' + parsed.trackingNumber +
', status=' + parsed.status + ', title=' + parsed.title;
} else {
feedback.style.color = '#c33';
feedback.textContent = '✗ does not match YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT';
}
});
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
box.querySelector('#stage-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled'));
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
box.querySelector('#stage-submit').addEventListener('click', function () {
var sel = box.querySelector('input[name="stage-target"]:checked');
if (!sel) { status('Pick a destination folder.', 'error'); return; }
if (sel.value === '__new__') {
var name = newInput.value.trim();
var parsed = window.zddc.parseFolder(name);
if (!parsed || !parsed.valid) {
status('Folder name must conform to ZDDC convention.', 'error');
return;
}
close(); resolve({ create: true, folderName: name });
} else {
close(); resolve({ create: false, folderName: sel.value });
}
});
});
}
// ── Unstage picker modal ───────────────────────────────────────────
function openUnstagePicker(initial) {
return new Promise(function (resolve, reject) {
var overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div');
box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);';
box.innerHTML =
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Unstage ' +
initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + '</h2>' +
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
'Move these files back into your drafting workspace under <code>working/</code> ' +
'so they can be corrected. Stage them again when ready.' +
'</p>' +
'<label for="unstage-target" style="font-size:0.9rem;">Destination folder</label>' +
'<input id="unstage-target" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" value="' +
escapeHtml(initial.defaultTarget) + '">' +
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
'<button type="button" id="unstage-cancel">Cancel</button>' +
'<button type="button" id="unstage-submit" class="btn-primary">Unstage</button>' +
'</div>';
overlay.appendChild(box);
document.body.appendChild(overlay);
var input = box.querySelector('#unstage-target');
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
box.querySelector('#unstage-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled'));
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
box.querySelector('#unstage-submit').addEventListener('click', function () {
var target = input.value.trim();
if (!target) { status('Destination is required.', 'error'); return; }
close(); resolve({ target: target });
});
});
}
// ── Action drivers ─────────────────────────────────────────────────
async function invokeStage(node) {
var tree = window.app.modules.tree;
if (!tree) return;
var srcUrl = tree.pathFor(node);
var info = projectAndSubtree(srcUrl);
if (!info || info.subtree !== 'working') {
status('Stage applies only to files under working/.', 'error');
return;
}
var stagingBase = '/' + info.project + '/staging/';
var folders;
try { folders = await fetchStagingFolders(info.project); }
catch (e) {
status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error');
return;
}
var choice;
try {
choice = await openStagePicker({ fileCount: 1, folders: folders });
} catch (_e) { return; }
if (choice.create) {
try {
await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/');
} catch (e) {
status((e && e.message) || 'mkdir failed', 'error');
return;
}
}
var dstUrl = stagingBase + encodeURIComponent(choice.folderName) + '/' + encodeURIComponent(node.name);
try {
await moveFile(srcUrl, dstUrl);
} catch (e) {
status((e && e.message) || 'move failed', 'error');
return;
}
status('Staged ' + node.name + ' → staging/' + choice.folderName + '/ — reload to see the move.', 'success');
}
async function invokeUnstage(node) {
var tree = window.app.modules.tree;
if (!tree) return;
var srcUrl = tree.pathFor(node);
var info = projectAndSubtree(srcUrl);
if (!info || info.subtree !== 'staging') {
status('Unstage applies only to files under staging/.', 'error');
return;
}
var email = await fetchSelfEmail();
var defaultTarget = '/' + info.project + '/working/' + (email || '') + '/';
var choice;
try {
choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget });
} catch (_e) { return; }
var target = choice.target;
if (!target.endsWith('/')) target += '/';
var dstUrl = target + encodeURIComponent(node.name);
try {
await moveFile(srcUrl, dstUrl);
} catch (e) {
status((e && e.message) || 'move failed', 'error');
return;
}
status('Unstaged ' + node.name + ' → ' + target + ' — reload to see the move.', 'success');
}
window.app.modules.stage = {
isStageableFile: isStageableFile,
isUnstageableFile: isUnstageableFile,
invokeStage: invokeStage,
invokeUnstage: invokeUnstage
};
})();

View file

@ -43,7 +43,13 @@
// True when this entry was synthesized client-side (e.g.
// canonical project folders that don't exist on disk yet).
// Rendered with a muted style + an "(empty)" hint.
virtual: !!raw.virtual
virtual: !!raw.virtual,
// Server-computed write authority. Editors (preview-yaml,
// preview-markdown) consult this via canSave() to decide
// whether to mount read-only. Dropping the field here
// silently makes every node read-only — the actual root
// cause behind "I'm admin but the editor says read-only".
writable: !!raw.writable
};
state.nodes.set(id, node);
return node;
@ -111,15 +117,24 @@
}
// Walk nodes in render order. Skips the children of a collapsed
// expandable.
// expandable. When state.filterAST is set, also skips nodes that
// don't match (files) or whose subtree has no matches (folders),
// and force-walks into folders that have matching descendants so
// those matches are visible even when the user hadn't expanded
// the folder. The user's actual node.expanded flag stays untouched
// so clearing the filter restores their original layout.
function visibleIds() {
var out = [];
function walk(ids) {
for (var i = 0; i < ids.length; i++) {
var n = state.nodes.get(ids[i]);
if (!n) continue;
if (state.filterAST && !passesFilter(n)) continue;
out.push(ids[i]);
if ((n.isDir || n.isZip) && n.expanded) walk(n.childIds);
if (n.isDir || n.isZip) {
var forceWalk = !!state.filterAST;
if (forceWalk || n.expanded) walk(n.childIds);
}
}
}
// Re-sort everything at all levels so a sort change reorders
@ -132,6 +147,59 @@
return out;
}
// ── Filter ─────────────────────────────────────────────────────────────
// Build the haystack string we run the filter AST against. We
// concatenate every searchable field — name, displayName, plus any
// ZDDC parts the basename parses to — so users can type a tracking
// number, a status code, a date, or a piece of the title.
function filterHaystack(node) {
var parts = [node.name];
if (node.displayName) parts.push(node.displayName);
var z = window.zddc;
if (z) {
var parsed = node.isDir ? z.parseFolder(node.name)
: z.parseFilename(node.name);
if (parsed && parsed.valid) {
if (parsed.trackingNumber) parts.push(parsed.trackingNumber);
if (parsed.title) parts.push(parsed.title);
if (parsed.status) parts.push(parsed.status);
if (parsed.revision) parts.push(parsed.revision);
if (parsed.date) parts.push(parsed.date);
}
}
return parts.join(' ');
}
function nodeMatchesFilter(node) {
if (!state.filterAST) return true;
return window.zddc.filter.matches(filterHaystack(node), state.filterAST);
}
// True when this node should appear in the filtered view: either
// the node itself matches, or it's an expandable with at least
// one matching descendant (so we keep the path to a match visible).
function passesFilter(node) {
if (!state.filterAST) return true;
if (nodeMatchesFilter(node)) return true;
if (!(node.isDir || node.isZip)) return false;
if (!node.loaded) return false; // unloaded subtrees aren't searched
for (var i = 0; i < node.childIds.length; i++) {
var child = state.nodes.get(node.childIds[i]);
if (child && passesFilter(child)) return true;
}
return false;
}
// Is this folder being "forced open" by an active filter because
// a descendant matches? Used by rowHtml to render the chevron as
// expanded without mutating node.expanded.
function filterForcesOpen(node) {
if (!state.filterAST) return false;
if (!(node.isDir || node.isZip)) return false;
return passesFilter(node) && !nodeMatchesFilter(node);
}
// ── Rendering ────────────────────────────────────────────────────────
function fmtSize(bytes) {
@ -154,6 +222,127 @@
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Per-extension icon map → Lucide outline-icon sprite ids. The
// actual SVG markup is produced by window.zddc.icons.html(id),
// which inlines `<svg><use href="#id"/></svg>` so the page CSS
// can size and tint via currentColor.
//
// book-marked PDF file-pen markdown
// file-text word / txt file-spreadsheet spreadsheet
// presentation slides file-image image
// file-video video file-audio audio
// ruler CAD / drawing globe web
// file-cog config / .zddc file-code source code
// file-archive non-nav archive folder-archive .zip (navigable)
// file generic folder directory
var ICON_BY_EXT = {
pdf: 'icon-book-marked',
md: 'icon-file-pen', markdown: 'icon-file-pen',
doc: 'icon-file-text', docx: 'icon-file-text', rtf: 'icon-file-text', odt: 'icon-file-text',
xls: 'icon-file-spreadsheet', xlsx: 'icon-file-spreadsheet',
csv: 'icon-file-spreadsheet', ods: 'icon-file-spreadsheet', tsv: 'icon-file-spreadsheet',
ppt: 'icon-presentation', pptx: 'icon-presentation', odp: 'icon-presentation',
txt: 'icon-file-text', log: 'icon-file-text',
jpg: 'icon-file-image', jpeg: 'icon-file-image', png: 'icon-file-image',
gif: 'icon-file-image', webp: 'icon-file-image', svg: 'icon-file-image',
bmp: 'icon-file-image', tif: 'icon-file-image', tiff: 'icon-file-image',
ico: 'icon-file-image', heic: 'icon-file-image',
mp4: 'icon-file-video', mov: 'icon-file-video', avi: 'icon-file-video',
mkv: 'icon-file-video', webm: 'icon-file-video', m4v: 'icon-file-video',
mp3: 'icon-file-audio', wav: 'icon-file-audio', flac: 'icon-file-audio',
ogg: 'icon-file-audio', m4a: 'icon-file-audio', aac: 'icon-file-audio',
dwg: 'icon-ruler', dxf: 'icon-ruler', step: 'icon-ruler',
stp: 'icon-ruler', iges: 'icon-ruler', igs: 'icon-ruler',
html: 'icon-globe', htm: 'icon-globe',
yaml: 'icon-file-cog', yml: 'icon-file-cog', json: 'icon-file-cog',
toml: 'icon-file-cog', ini: 'icon-file-cog', xml: 'icon-file-cog',
conf: 'icon-file-cog', cfg: 'icon-file-cog',
'7z': 'icon-file-archive', rar: 'icon-file-archive', tar: 'icon-file-archive',
gz: 'icon-file-archive', tgz: 'icon-file-archive',
bz2: 'icon-file-archive', xz: 'icon-file-archive',
// Code — share one glyph across languages so users build the
// "this is source" pattern. Distinguishing per language would
// be visual noise without much added signal.
js: 'icon-file-code', mjs: 'icon-file-code', cjs: 'icon-file-code',
ts: 'icon-file-code', tsx: 'icon-file-code', jsx: 'icon-file-code',
py: 'icon-file-code', go: 'icon-file-code', rs: 'icon-file-code',
c: 'icon-file-code', cc: 'icon-file-code', cpp: 'icon-file-code',
h: 'icon-file-code', hpp: 'icon-file-code', java: 'icon-file-code',
rb: 'icon-file-code', php: 'icon-file-code', sh: 'icon-file-code',
bash: 'icon-file-code', zsh: 'icon-file-code', lua: 'icon-file-code',
swift: 'icon-file-code', kt: 'icon-file-code', kts: 'icon-file-code',
css: 'icon-file-code', scss: 'icon-file-code', less: 'icon-file-code'
};
function symbolForNode(node) {
if (node.isDir) return 'icon-folder';
if (node.isZip) return 'icon-folder-archive';
// `.zddc` (no extension) is the cascade config — same family
// as yaml. Match the literal basename before falling through
// to the extension table.
if (node.name === '.zddc') return 'icon-file-cog';
var ext = (node.ext || '').toLowerCase();
return ICON_BY_EXT[ext] || 'icon-file';
}
function iconForNode(node) {
return window.zddc.icons.html(symbolForNode(node));
}
// Render the label cell for a row. When the basename parses as a
// ZDDC-conformant filename (files) or transmittal folder name
// (directories), split into a two-line layout:
// top — trackingNumber · [revision · ]status (small, muted)
// bot — title (normal weight)
// Otherwise fall back to a single line.
//
// .zddc `display:` overrides always render as a single line — the
// operator chose that string for a reason; we don't try to second-
// guess it by parsing for ZDDC structure.
function labelHtml(node) {
// No native title="…" — the rich hovercard (browse/js/hovercard.js)
// replaces the browser tooltip with a metadata view that's
// both more informative and styled to match the rest of the UI.
if (node.displayName) {
return '<span class="tree-name__label">'
+ escapeHtml(node.displayName)
+ '</span>';
}
var z = window.zddc;
var parsed = null;
if (z) {
parsed = node.isDir
? z.parseFolder(node.name)
: z.parseFilename(node.name);
}
if (parsed && parsed.valid) {
// Folders carry a date (no revision); files carry a
// revision (no date). Status is present on both.
var parts;
if (node.isDir) {
parts = [parsed.date, parsed.trackingNumber, parsed.status];
} else {
parts = [parsed.trackingNumber, parsed.revision, parsed.status];
}
var metaText = parts.filter(Boolean).join(' · ');
// Title-first: primary content on the top line so the row
// reads like a normal file manager / mail list. Meta sits
// below as the supporting "subtitle" — same hierarchy
// pattern as Gmail, Linear, Notion file rows.
return '<span class="tree-name__label tree-name__label--zddc">'
+ '<span class="tree-name__title">'
+ escapeHtml(parsed.title)
+ '</span>'
+ '<span class="tree-name__meta">'
+ escapeHtml(metaText)
+ '</span>'
+ '</span>';
}
return '<span class="tree-name__label">'
+ escapeHtml(node.name)
+ '</span>';
}
// Render a single tree row as a flat <div>. Indentation via
// padding-left so the row's hover background spans the full
// pane width. Files are rendered as plain rows (no anchor) —
@ -163,26 +352,45 @@
function rowHtml(node) {
var indent = 0.4 + node.depth * 1.0;
var expandable = node.isDir || node.isZip;
var iconChar = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄');
var iconChar = iconForNode(node);
var chevronClass = 'tree-name__chevron'
+ (expandable ? '' : ' tree-name__chevron--leaf');
// Outline Lucide chevron — single sprite glyph, rotated 90°
// via CSS for the expanded state. Leaf rows ship an empty
// chevron span so the icon column stays aligned.
var chevronGlyph = expandable
? window.zddc.icons.html('icon-chevron-right')
: '';
// While a filter is active, folders that contain a matching
// descendant are rendered as visually expanded so the user
// can see the match — even if node.expanded is still false.
// The actual flag stays untouched so clearing the filter
// restores the user's original tree shape.
var visuallyExpanded = node.expanded || filterForcesOpen(node);
var selected = state.selectedId === node.id ? ' is-selected' : '';
var virtualCls = node.virtual ? ' tree-row--virtual' : '';
// No native title — the hovercard surfaces a dedicated
// "Virtual: Not yet created on disk" row for these nodes.
var virtualHint = node.virtual
? '<span class="tree-name__hint" title="Folder not yet created on disk — opens an empty workspace">(empty)</span>'
? '<span class="tree-name__hint">(empty)</span>'
: '';
// Extension chip stacked under the file icon. Files with a
// non-empty ext get a small uppercase label; folders / zips
// skip it (the chevron + icon glyph carries enough info).
var extChip = (!node.isDir && !node.isZip && node.ext)
? '<span class="tree-name__ext">' + escapeHtml(String(node.ext)) + '</span>'
: '';
return ''
+ '<div class="tree-row ' + (node.expanded ? 'expanded' : '') + selected + virtualCls
+ '<div class="tree-row ' + (visuallyExpanded ? 'expanded' : '') + selected + virtualCls
+ '" data-id="' + node.id
+ '" data-isdir="' + node.isDir
+ '" data-iszip="' + node.isZip + '"'
+ (node.virtual ? ' data-virtual="true"' : '')
+ ' style="padding-left:' + indent + 'rem"'
+ ' role="treeitem" tabindex="-1">'
+ '<span class="' + chevronClass + '"></span>'
+ '<span class="tree-name__icon">' + iconChar + '</span>'
+ '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">'
+ escapeHtml(node.displayName || node.name) + '</span>'
+ '<span class="' + chevronClass + '">' + chevronGlyph + '</span>'
+ '<span class="tree-name__icon">' + iconChar + extChip + '</span>'
+ labelHtml(node)
+ virtualHint
+ '</div>';
}
@ -196,33 +404,9 @@
html += rowHtml(state.nodes.get(ids[i]));
}
body.innerHTML = html;
updateCount();
renderBreadcrumbs();
}
// Count nodes that render at the root + every expanded subtree.
function expandedSetSize() {
var n = 0;
function walk(ids) {
for (var i = 0; i < ids.length; i++) {
n++;
var node = state.nodes.get(ids[i]);
if (node && (node.isDir || node.isZip) && node.expanded) {
walk(node.childIds);
}
}
}
walk(state.rootIds);
return n;
}
function updateCount() {
var el = document.getElementById('entryCount');
if (!el) return;
var total = expandedSetSize();
el.textContent = total + ' item' + (total === 1 ? '' : 's');
}
// ── Breadcrumbs ──────────────────────────────────────────────────────
// Inline outline home icon. Stroke-based so it tints with the
@ -431,6 +615,61 @@
return parts.join('/');
}
// ── State snapshot / restore ───────────────────────────────────────────
//
// Used by refresh + show-hidden so the user doesn't lose their
// tree layout when the listing reloads. The key is the absolute
// path of each node, computed by pathFor; on restore we walk the
// new tree and re-apply expansion + selection to nodes whose
// paths match.
function snapshotState() {
var expanded = {};
var selectedPath = null;
var previewPath = null;
state.nodes.forEach(function (n) {
if ((n.isDir || n.isZip) && n.expanded) {
expanded[pathFor(n)] = true;
}
if (n.id === state.selectedId) selectedPath = pathFor(n);
if (n.id === state.lastPreviewedNodeId) previewPath = pathFor(n);
});
return {
expanded: expanded,
selectedPath: selectedPath,
previewPath: previewPath
};
}
// Walk the current tree (already populated by setRoot) and re-
// load + expand every folder whose path appears in snapshot.expanded.
// Sets selectedId and lastPreviewedNodeId by matching the snapshot
// paths to the freshly-issued node IDs.
async function restoreState(snap) {
if (!snap) return;
async function walk(ids) {
for (var i = 0; i < ids.length; i++) {
var n = state.nodes.get(ids[i]);
if (!n) continue;
var p = pathFor(n);
if (snap.selectedPath && p === snap.selectedPath) {
state.selectedId = n.id;
}
if (snap.previewPath && p === snap.previewPath) {
state.lastPreviewedNodeId = n.id;
}
if ((n.isDir || n.isZip) && snap.expanded[p]) {
await loadChildren(n);
if (n.loaded) {
n.expanded = true;
await walk(n.childIds);
}
}
}
}
await walk(state.rootIds);
}
// Public API
window.app.modules.tree = {
setRoot: setRoot,
@ -439,6 +678,9 @@
toggleFolder: toggleFolder,
expandSubtree: expandSubtree,
collapseSubtree: collapseSubtree,
loadChildren: loadChildren,
snapshotState: snapshotState,
restoreState: restoreState,
setSort: function (key) {
if (state.sort.key === key) {
state.sort.dir = -state.sort.dir;
@ -455,6 +697,7 @@
state.sort.dir = (dir === -1 ? -1 : 1);
render();
},
pathFor: pathFor
pathFor: pathFor,
visibleIds: visibleIds
};
})();

View file

@ -85,13 +85,17 @@
return false;
}
function uploadUrl(filename) {
var base = state.currentPath || '/';
// Join a directory path and a relative path safely. dir is expected
// to be /-prefixed and may or may not have a trailing /; rel is a
// forward-slash relative path (no leading /). Each segment is
// URI-encoded so spaces and friends survive the round trip.
function joinUrl(dir, rel) {
var base = dir || '/';
if (!base.endsWith('/')) base += '/';
return base + encodeURIComponent(filename);
return base + rel.split('/').map(encodeURIComponent).join('/');
}
async function uploadOne(file) {
async function uploadOne(file, destDir, relPath) {
if (file.size > UPLOAD_MAX_BYTES) {
return {
file: file,
@ -101,7 +105,7 @@
};
}
try {
var resp = await fetch(uploadUrl(file.name), {
var resp = await fetch(joinUrl(destDir, relPath), {
method: 'PUT',
body: file,
credentials: 'same-origin',
@ -125,6 +129,351 @@
}
}
// ── Folder-upload helpers (webkitGetAsEntry recursion) ─────────────────
// Browsers expose dropped folders only through the entries API.
// walkEntry flattens a tree into [{ relPath, file }] so uploadOne
// can PUT each file individually. The server's PUT auto-creates
// intermediate directories, so no explicit mkdir is needed.
function readAllEntries(reader) {
return new Promise(function (resolve, reject) {
var collected = [];
function loop() {
reader.readEntries(function (batch) {
if (batch.length === 0) return resolve(collected);
collected = collected.concat(batch);
loop();
}, reject);
}
loop();
});
}
function entryToFile(entry) {
return new Promise(function (resolve, reject) {
entry.file(resolve, reject);
});
}
async function walkEntry(entry, prefix, out) {
if (entry.isFile) {
try {
var f = await entryToFile(entry);
out.push({ relPath: prefix + entry.name, file: f });
} catch (_e) { /* skip unreadable file */ }
} else if (entry.isDirectory) {
var reader = entry.createReader();
var kids = await readAllEntries(reader);
for (var i = 0; i < kids.length; i++) {
await walkEntry(kids[i], prefix + entry.name + '/', out);
}
}
}
// Extract { relPath, file } pairs from a DataTransfer. Uses
// webkitGetAsEntry when available (so folder uploads work);
// falls back to dataTransfer.files for cases where entries
// aren't exposed (some browsers / cross-origin).
async function collectUploads(dt) {
var out = [];
if (dt.items && dt.items.length) {
var entries = [];
for (var i = 0; i < dt.items.length; i++) {
var item = dt.items[i];
if (item.kind !== 'file') continue;
var entry = typeof item.webkitGetAsEntry === 'function'
? item.webkitGetAsEntry()
: null;
if (entry) {
entries.push(entry);
} else {
var f = item.getAsFile();
if (f) out.push({ relPath: f.name, file: f });
}
}
for (var j = 0; j < entries.length; j++) {
await walkEntry(entries[j], '', out);
}
if (out.length) return out;
}
if (dt.files) {
for (var k = 0; k < dt.files.length; k++) {
out.push({ relPath: dt.files[k].name, file: dt.files[k] });
}
}
return out;
}
// Run a batch of uploads against an arbitrary destination directory.
// Surfaces per-file errors as toasts; refreshes the tree afterward
// so newly-uploaded entries appear. Returns { ok, fail } counts.
async function uploadBatch(uploads, destDir) {
var note = window.zddc && window.zddc.toast;
if (note) {
note('Uploading ' + uploads.length + ' item'
+ (uploads.length === 1 ? '' : 's') + '…', 'info');
}
var ok = 0, fail = 0;
for (var i = 0; i < uploads.length; i++) {
var u = uploads[i];
var res = await uploadOne(u.file, destDir, u.relPath);
if (res.ok) ok++;
else {
fail++;
if (note) {
note('Upload failed: ' + u.relPath + ' — ' + res.message, 'error');
}
}
}
if (note) {
if (fail === 0) {
note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's')
+ ' → ' + destDir, 'success');
} else if (ok === 0) {
note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error');
} else {
note(ok + ' uploaded, ' + fail + ' failed', 'warning');
}
}
return { ok: ok, fail: fail };
}
// Comment upload: PUT each dropped file's bytes to the target URL.
// The server detects the virtual <workflow>/received/ context and
// rewrites the destination to <workflow>/<base>+C<n><suffix>, surfacing
// the resolved path in X-ZDDC-Resolved-Path so the status line can
// tell the user where the bytes landed.
async function uploadCommentToTarget(targetURL, dataTransfer) {
var note = window.zddc && window.zddc.toast;
var files = [];
if (dataTransfer.files && dataTransfer.files.length) {
for (var k = 0; k < dataTransfer.files.length; k++) {
files.push(dataTransfer.files[k]);
}
}
if (files.length === 0) {
if (note) note('No files to upload.', 'warning');
return;
}
var ok = 0;
var lastResolved = '';
for (var i = 0; i < files.length; i++) {
var f = files[i];
if (f.size > UPLOAD_MAX_BYTES) {
if (note) note('Skipped (too large): ' + f.name, 'error');
continue;
}
try {
var resp = await fetch(targetURL, {
method: 'PUT',
body: f,
credentials: 'same-origin',
headers: { 'Content-Type': f.type || 'application/octet-stream' }
});
if (resp.ok) {
ok++;
var hdr = resp.headers.get('X-ZDDC-Resolved-Path') || '';
if (hdr) lastResolved = hdr;
} else if (note) {
note('Comment upload failed (' + resp.status + ')', 'error');
}
} catch (e) {
if (note) note('Comment upload error: ' + (e && e.message), 'error');
}
}
if (note && ok > 0) {
var msg = 'Saved ' + ok + ' comment' + (ok === 1 ? '' : 's');
if (lastResolved) msg += ' — last at ' + lastResolved;
note(msg, 'success');
}
// Reload the listing of the workflow folder so the new +Cn file
// appears in the tree. The workflow folder is the parent of the
// virtual `received/` (i.e., the URL with one `/received/<file>`
// suffix stripped).
var refreshUrl = targetURL.replace(/\/received\/[^/]+\/?$/, '/');
try {
var ev = window.app.modules.events;
if (ev && typeof ev.refreshListing === 'function') {
ev.refreshListing();
} else if (refreshUrl) {
// Best-effort fallback: re-navigate to the workflow folder
// so its listing is refreshed.
// (No action — refreshListing absence implies older browse.)
}
} catch (_e) { /* refresh is best-effort */ }
}
// ── Create-new helpers ────────────────────────────────────────────────
// Both go through the same server endpoints used by upload: PUT
// for files (with an empty/template body) and POST + X-ZDDC-Op:
// mkdir for directories. Client-side enforcement is best-effort;
// the server's ACL is the source of truth.
async function makeDir(parentDir, name) {
var url = joinUrl(parentDir, name);
if (!url.endsWith('/')) url += '/';
var resp = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-ZDDC-Op': 'mkdir' }
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
}
async function makeFile(parentDir, name, body, contentType) {
var resp = await fetch(joinUrl(parentDir, name), {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': contentType || 'application/octet-stream' },
body: body == null ? '' : body
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
}
// ── Delete + rename ─────────────────────────────────────────────────────
// Both run through the same FS Access API + file-API endpoints used
// by the create helpers above:
// - Server mode: DELETE / POST X-ZDDC-Op: move. ACL is enforced
// server-side; a 403/405 surfaces as an error toast.
// - FS-API mode: FileSystemHandle.remove({recursive:true}) and
// .move(newName) — both are Chromium-110+ features. We feature-
// detect at the handle level; callers see a clear "not supported"
// error message if the browser is too old.
function pathForNode(node) {
var tree = window.app.modules.tree;
return tree ? tree.pathFor(node) : '';
}
function isZipMember(node) {
if (node.handle && node.handle.isZipEntry) return true;
if (node.url && state.source === 'server' && /\.zip\//i.test(node.url)) {
return true;
}
return false;
}
// True when this node's write API is reachable. The server can
// still refuse the action on ACL grounds; this only gates the
// menu's disabled-state for the cases where there's clearly no
// write target at all.
function canMutate(node) {
if (!node || node.virtual) return false;
if (isZipMember(node)) return false;
if (state.source === 'server') return true;
if (node.handle && typeof node.handle.remove === 'function') return true;
return false;
}
async function removeNode(node) {
if (!node) throw new Error('no node');
if (isZipMember(node)) {
throw new Error('Cannot delete a file inside a zip archive.');
}
if (node.virtual) {
throw new Error('Virtual folder — nothing on disk to delete.');
}
if (state.source === 'server') {
var url = pathForNode(node);
if (node.isDir && !url.endsWith('/')) url += '/';
var resp = await fetch(url, {
method: 'DELETE',
credentials: 'same-origin'
});
if (!resp.ok) {
if (resp.status === 403) throw new Error('Permission denied (403).');
if (resp.status === 405) throw new Error('Delete not allowed for this entry.');
throw new Error('HTTP ' + resp.status);
}
return;
}
// FS-API path. FileSystemHandle.remove() is Chromium 110+
// (browsers that didn't ship it expose no equivalent — the
// legacy removeEntry() lives on the PARENT directory handle
// and we don't retain ancestor handles).
if (node.handle && typeof node.handle.remove === 'function') {
await node.handle.remove({ recursive: !!node.isDir });
return;
}
throw new Error('Delete not supported by this browser in offline mode.');
}
async function renameNode(node, newName) {
if (!node) throw new Error('no node');
if (!newName) throw new Error('Name required.');
if (newName === node.name) return;
if (isZipMember(node)) {
throw new Error('Cannot rename a file inside a zip archive.');
}
if (node.virtual) {
throw new Error('Virtual folder — nothing on disk to rename.');
}
if (state.source === 'server') {
var src = pathForNode(node);
if (node.isDir && !src.endsWith('/')) src += '/';
// Destination = same parent, new basename.
var lastSlash = src.replace(/\/$/, '').lastIndexOf('/');
var parent = lastSlash >= 0 ? src.substring(0, lastSlash + 1) : '/';
var dst = parent + encodeURIComponent(newName) + (node.isDir ? '/' : '');
var resp = await fetch(src, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-ZDDC-Op': 'move',
'X-ZDDC-Destination': dst
}
});
if (!resp.ok) {
if (resp.status === 403) throw new Error('Permission denied (403).');
if (resp.status === 409) throw new Error('A file with that name already exists.');
throw new Error('HTTP ' + resp.status);
}
return;
}
// FS-API: handle.move(newName) is Chromium 110+.
if (node.handle && typeof node.handle.move === 'function') {
await node.handle.move(newName);
return;
}
throw new Error('Rename not supported by this browser in offline mode.');
}
// Refresh either the root listing (when the upload targeted the
// current scope) or just one folder node's children (when the
// upload targeted a subfolder via a per-row drop).
async function refreshAfterUpload(targetDir) {
var loader = window.app.modules.loader;
var tree = window.app.modules.tree;
if (!loader || !tree) return;
if (state.currentPath && targetDir === state.currentPath) {
try {
var es = await loader.fetchServerChildren(state.currentPath);
tree.setRoot(es);
tree.render();
} catch (_e) { /* swallow */ }
return;
}
// Find any tree node whose path matches targetDir and reload
// its children. Walks state.nodes flat — n is small enough for
// a linear scan.
var dirNoSlash = (targetDir || '').replace(/\/$/, '');
var hit = null;
state.nodes.forEach(function (n) {
if (hit || !n.isDir) return;
if (tree.pathFor(n).replace(/\/$/, '') === dirNoSlash) hit = n;
});
if (hit && hit.expanded) {
try {
var raw = await loader.fetchServerChildren(targetDir);
tree.setChildren(hit.id, raw);
tree.render();
} catch (_e) { /* swallow */ }
}
}
// Document-level drop: targets the currently-viewed scope. The
// per-row drop (events.js) calls uploadToDir directly with a
// different destination.
async function handleDrop(e) {
e.preventDefault();
e.stopPropagation();
@ -133,46 +482,21 @@
if (!currentScopeAllows()) return;
var dt = e.dataTransfer;
if (!dt || !dt.files || dt.files.length === 0) return;
if (!dt) return;
var uploads = await collectUploads(dt);
if (!uploads.length) return;
await uploadBatch(uploads, state.currentPath);
await refreshAfterUpload(state.currentPath);
}
var files = Array.from(dt.files);
var note = window.zddc && window.zddc.toast;
if (note) note('Uploading ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '…', 'info');
// Sequential — predictable progress + ordering. Can parallelise
// later if it matters.
var ok = 0, fail = 0;
for (var i = 0; i < files.length; i++) {
var res = await uploadOne(files[i]);
if (res.ok) {
ok++;
} else {
fail++;
if (note) {
note('Upload failed: ' + res.file.name + ' — ' + res.message, 'error');
}
}
}
if (note) {
if (fail === 0) {
note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's'), 'success');
} else if (ok === 0) {
note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error');
} else {
note(ok + ' uploaded, ' + fail + ' failed', 'warning');
}
}
// Refresh the listing so newly-uploaded files appear.
var loader = window.app.modules.loader;
var tree = window.app.modules.tree;
if (loader && tree && state.currentPath) {
try {
var es = await loader.fetchServerChildren(state.currentPath);
tree.setRoot(es);
tree.render();
} catch (_e) { /* swallow; user can hard-reload */ }
}
// Public entry for per-row drops or programmatic uploads. destDir
// must be a server path (/-prefixed, slash-terminated optional).
async function uploadToDir(destDir, dataTransfer) {
var uploads = await collectUploads(dataTransfer);
if (!uploads.length) return { ok: 0, fail: 0 };
var res = await uploadBatch(uploads, destDir);
await refreshAfterUpload(destDir);
return res;
}
function onEnter(e) {
@ -215,6 +539,13 @@
window.app.modules.upload = {
currentScopeAllows: currentScopeAllows,
uploadToDir: uploadToDir,
uploadCommentToTarget: uploadCommentToTarget,
makeDir: makeDir,
makeFile: makeFile,
removeNode: removeNode,
renameNode: renameNode,
canMutate: canMutate,
UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES
};
})();

View file

@ -24,10 +24,16 @@
<span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
</div>
<div class="header-right">
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div>
@ -41,7 +47,7 @@
<ul class="welcome-list">
<li><b>Online</b> — when this page is served by zddc-server, the
listing for the current directory loads automatically.</li>
<li><b>Local</b> — click <i>Add Local Directory</i> to pick any folder
<li><b>Local</b> — click <i>Use Local Directory</i> to pick any folder
on your computer (Chromium-based browsers).</li>
</ul>
<p>Once loaded: click folders to expand, click files to preview them in
@ -54,33 +60,20 @@
<div id="browseRoot" class="browse-root hidden">
<div class="browse-toolbar">
<nav class="breadcrumbs" id="breadcrumbs" aria-label="Path"></nav>
<span class="toolbar__count" id="entryCount"></span>
<button id="downloadZipBtn" class="btn btn-sm btn-secondary hidden"
title="Download this folder (and everything under it you can access) as a .zip"
aria-label="Download this folder as a zip">⤓ Download (zip)</button>
<label class="sort-control" for="sortBy" title="Sort tree entries">
<span class="sort-control__label">Sort:</span>
<select id="sortBy" class="sort-control__select" aria-label="Sort tree entries">
<option value="name:asc">Name (A→Z)</option>
<option value="name:desc">Name (Z→A)</option>
<option value="date:desc">Modified (new→old)</option>
<option value="date:asc">Modified (old→new)</option>
<option value="size:desc">Size (large→small)</option>
<option value="size:asc">Size (small→large)</option>
<option value="ext:asc">Type (A→Z)</option>
</select>
</label>
<label class="sort-control" for="showHidden"
title="Surface .-prefixed and _-prefixed entries (.zddc, .converted/, _app/, …). ACL still applies — you only see what you'd already be allowed to read.">
<input type="checkbox" id="showHidden" class="sort-control__checkbox"
aria-label="Show hidden files">
<span class="sort-control__label">Show hidden</span>
</label>
</div>
<!-- Browse mode (default): two-pane tree + preview -->
<div id="browseView" class="browse-view">
<div class="pane tree-pane" id="treePane">
<div class="tree-pane__toolbar">
<input type="search"
id="treeFilter"
class="tree-filter"
placeholder="Filter files…"
aria-label="Filter the tree by name, tracking number, status, revision, or title"
autocomplete="off"
spellcheck="false">
</div>
<div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div>
</div>
<div class="pane-resizer" data-resizer-for="tree-pane" aria-hidden="true"></div>
@ -135,18 +128,22 @@
<dd>Recursive expand or collapse — the whole subtree.</dd>
<dt>Click a file</dt>
<dd>Preview it in the right pane.</dd>
<dt>Right-click any row</dt>
<dd>Opens a context menu with Open, Download, Copy path, Sort, and
folder-specific actions. Toggle items show a ✓ when active; submenus
open on hover.</dd>
<dt>⤴ Pop out</dt>
<dd>Open the current preview in a separate window — useful for a second
monitor.</dd>
<dt>ZIP files</dt>
<dd>Behave as folders — click to inspect contents inline. JSZip is
bundled, so this works offline.</dd>
<dt>⤓ Download (zip)</dt>
<dd>Downloads the directory you're currently viewing — and everything
under it that you're allowed to see — as a single <code>.zip</code>.
Navigate into a subfolder first to download just that subtree. Online,
the server streams it; locally, the browser bundles the picked folder
(a confirmation appears if it's very large).</dd>
<dt>Download / Download ZIP</dt>
<dd>Right-click a file for <b>Download</b>, or a folder for
<b>Download ZIP</b> (everything under it that you're allowed to see,
bundled into one archive). Online, the server streams it; locally,
the browser bundles the picked folder (a confirmation appears if it's
very large).</dd>
<dt>Refresh</dt>
<dd>Re-fetches the current directory listing — works for both
local (re-enumerates the FS handle) and online (re-fetches the JSON).</dd>
@ -154,7 +151,7 @@
<h3>Header buttons</h3>
<dl>
<dt>Add Local Directory</dt>
<dt>Use Local Directory</dt>
<dd>Pick a folder from your computer. Works in both modes; in online
mode it's de-emphasized but still available.</dd>
<dt>⟳ Refresh</dt>

4
classifier/build.sh Normal file → Executable file
View file

@ -22,7 +22,7 @@ concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/elevation.css" \
"../shared/logo.css" \
"css/base.css" \
"css/layout.css" \
@ -44,7 +44,6 @@ concat_files \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/preview-lib.js" \
"js/app.js" \
@ -62,6 +61,7 @@ concat_files \
"js/sort.js" \
"js/excel.js" \
"../shared/help.js" \
"../shared/elevation.js" \
> "$js_raw"
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents

View file

@ -28,11 +28,17 @@
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
</div>
</header>
@ -154,7 +160,7 @@
<li>Rename one file or all modified files at once</li>
</ul>
<p>Click <strong>Add Local Directory</strong> to begin.</p>
<p>Click <strong>Use Local Directory</strong> to begin.</p>
<p class="note">This application works entirely in your browser. No data is transmitted to any server.</p>
</div>
@ -173,7 +179,7 @@
<h3>Getting Started</h3>
<ol>
<li>Click <strong>Add Local Directory</strong> to open a folder containing files to rename.</li>
<li>Click <strong>Use Local Directory</strong> to open a folder containing files to rename.</li>
<li>The folder tree on the left shows all sub-folders. Click a folder to load its files.</li>
<li>Edit cells in the spreadsheet to set the new filename components.</li>
<li>Click <strong>Save All</strong> (or save individual rows) to rename the files on disk.</li>

View file

@ -21,7 +21,7 @@ concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/elevation.css" \
"../shared/logo.css" \
"css/form.css" \
> "$css_temp"
@ -29,9 +29,9 @@ concat_files \
concat_files \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"js/app.js" \
"js/context.js" \
"js/util.js" \

View file

@ -26,6 +26,12 @@
</div>
</div>
<div class="header-right">
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div>

View file

@ -64,7 +64,36 @@ spec:
- name: zddc-server
image: {{ printf "%s:%s" .Values.runtimeImage.repository .Values.runtimeImage.tag | quote }}
imagePullPolicy: IfNotPresent
command: ["/zddc/zddc-server"]
# zddc-cgroup-init prepares cgroup v2 subtree_control then
# exec's zddc-server. Required because cgroup v2 forbids
# processes in a cgroup that has child cgroups; the per-
# conversion wrapper (zddc-sandbox-exec) creates child
# cgroups for resource caps, so the init script has to
# move zddc-server itself out of the root cgroup first.
# See zddc/runtime/zddc-cgroup-init in the source repo.
command: ["/usr/local/libexec/zddc-cgroup-init", "/zddc/zddc-server"]
# The conversion sandbox (bwrap, invoked per-call by
# /usr/local/bin/{pandoc,chromium-browser}) needs to create
# user + mount namespaces inside the container. Pod Security
# Standards default policies forbid this; the chart sets the
# minimum securityContext that lets bwrap function. If your
# cluster's admission controller rejects these settings, you
# have two choices: ask the platform team to allow this pod,
# or accept that /.convert serves 503 (the rest of zddc-
# server still works fine without conversion).
securityContext:
capabilities:
add: ["SYS_ADMIN"]
# cap-add SYS_ADMIN alone isn't enough — see the
# zddc/runtime/zddc-sandbox-exec docstring for the full
# set of LSM relaxations required. K8s 1.30+ supports
# specifying seccompProfile + appArmorProfile fields;
# if your cluster is older, you'll need annotations:
# container.apparmor.security.beta.kubernetes.io/zddc-server: unconfined
seccompProfile:
type: Unconfined
appArmorProfile:
type: Unconfined
ports:
- name: http
containerPort: 8080

View file

@ -108,11 +108,16 @@ buildImage:
tag: 1.24-alpine
# digest: sha256:...
# Runtime image (main container). Must contain a basic shell + libc;
# the static binary is copied in by the init container. Alpine is fine.
# Runtime image (main container). Hosts the zddc-server binary copied
# in by the init container, plus the conversion toolchain (pandoc,
# chromium, bubblewrap) used by the /.convert endpoint. Build from
# `zddc/runtime.Containerfile` and publish to your registry; the
# Containerfile documents the build/publish commands. Plain alpine
# does NOT have the conversion tools — the /.convert endpoint will
# serve 503 until you swap in a runtime image that bundles them.
runtimeImage:
repository: docker.io/alpine
tag: "3.19"
repository: codeberg.org/varasys/zddc-server-runtime
tag: "latest"
# digest: sha256:...
# Image pull credentials, if your registry requires them. Reference a

View file

@ -21,7 +21,7 @@ concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/elevation.css" \
"../shared/logo.css" \
"css/landing.css" \
> "$css_temp"
@ -31,9 +31,9 @@ concat_files \
"../shared/zddc-filter.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"js/landing.js" \
> "$js_raw"

View file

@ -128,13 +128,27 @@
var data = JSON.parse(body);
if (!Array.isArray(data)) throw new Error('Expected a JSON array of projects, got ' + typeof data);
allProjects = data.map(function(p) {
return {
name: String(p.name || ''),
title: String(p.title || ''),
url: String(p.url || '')
};
}).filter(function(p) { return p.name; });
// The root JSON is now a generic listing.FileInfo[] (same
// shape every other directory returns). Filter to
// directories (projects are folders), strip the trailing
// "/" the server adds to dir names, and pick up `title`
// (the per-project .zddc title:, populated by the
// server-side listing pipeline).
allProjects = data
.filter(function (p) { return p && p.is_dir; })
.map(function (p) {
var raw = String(p.name || '').replace(/\/$/, '');
return {
name: raw,
title: String(p.title || ''),
url: String(p.url || '')
};
})
.filter(function (p) {
if (!p.name) return false;
var c = p.name.charAt(0);
return c !== '.' && c !== '_';
});
return true;
} catch (e) {
loadError = e.message || String(e);

View file

@ -26,6 +26,12 @@
</div>
</div>
<div class="header-right">
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div>

View file

@ -230,7 +230,7 @@ a:hover {
}
/* Subdued / de-emphasized variant.
Used on the "Add Local Directory" button when a tool is operating
Used on the "Use Local Directory" button when a tool is operating
in server (online) mode the local-dir affordance is still
available but visually quieter, since the typical user already
has the directory loaded from the server. */
@ -292,6 +292,11 @@ a:hover {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
/* Let the left / right groups wrap to a second row at narrow
viewports rather than overflowing the viewport edge. row-gap
gives a small breathing strip when wrapped. */
flex-wrap: wrap;
row-gap: 0.3rem;
}
/* Left and right groups inside .app-header. Both flex-row so their
@ -303,16 +308,35 @@ a:hover {
display: flex;
align-items: center;
gap: 0.75rem;
/* Allow the title to shrink (and ellipsize) before the action
buttons get pushed off-screen at narrow viewports. */
min-width: 0;
flex-wrap: wrap;
row-gap: 0.3rem;
}
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
/* Title group (title + build label). Made shrinkable so narrow
viewports don't push the action buttons out of view; the title
itself ellipsizes via the rule below. */
.header-title-group {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
flex-shrink: 1;
}
/* Tool name inside the header. Renders in the display serif so the
tool's identity reads as a document title, not a UI label. */
tool's identity reads as a document title, not a UI label.
overflow + ellipsis on min-width:0 lets the title compress
gracefully when there's no room. */
.app-header__title {
font-family: var(--font-display);
font-size: 18px;
@ -320,6 +344,9 @@ a:hover {
color: var(--text);
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* Brand logo sits left of the title in every tool's app-header.

109
shared/context-menu.css Normal file
View file

@ -0,0 +1,109 @@
/* shared/context-menu.css generic styles for window.zddc.menu.
Mirrors the look-and-feel of native context menus: tight rows,
five-column grid (check | icon | label | accel | arrow), subtle
border + shadow, hover background from the shared --bg-hover token,
danger items tinted with --danger. */
.zddc-menu {
position: fixed;
z-index: 10000;
min-width: 12rem;
max-width: 22rem;
padding: 0.25rem 0;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18),
0 2px 6px rgba(0, 0, 0, 0.10);
font-family: var(--font);
font-size: 0.85rem;
line-height: 1.2;
user-select: none;
/* Allow focus styles inside without leaking to the menu itself. */
outline: none;
}
.zddc-menu__sep {
height: 1px;
margin: 0.25rem 0;
background: var(--border);
}
.zddc-menu__item {
display: grid;
grid-template-columns: 1.1rem 1.25rem 1fr auto 0.9rem;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.7rem;
cursor: pointer;
color: var(--text);
/* Suppress the focus ring on the row itself hover/focus
background handles the cue. */
outline: none;
}
.zddc-menu__item:hover,
.zddc-menu__item:focus,
.zddc-menu__item:focus-visible {
background: var(--bg-hover);
}
.zddc-menu__item.is-disabled {
color: var(--text-muted);
cursor: default;
}
.zddc-menu__item.is-disabled:hover,
.zddc-menu__item.is-disabled:focus {
background: transparent;
}
.zddc-menu__item--danger {
color: var(--danger);
}
.zddc-menu__item--danger:hover,
.zddc-menu__item--danger:focus {
background: var(--danger);
color: var(--text-light);
}
.zddc-menu__check {
font-size: 0.9rem;
text-align: center;
color: var(--primary);
}
.zddc-menu__icon {
font-size: 0.95rem;
text-align: center;
}
.zddc-menu__label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.zddc-menu__accel {
color: var(--text-muted);
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
padding-left: 0.5rem;
}
.zddc-menu__item--danger .zddc-menu__accel {
color: inherit;
opacity: 0.85;
}
.zddc-menu__arrow {
color: var(--text-muted);
font-size: 0.7rem;
text-align: center;
}
.zddc-menu__item--has-sub .zddc-menu__arrow {
color: var(--text);
}

381
shared/context-menu.js Normal file
View file

@ -0,0 +1,381 @@
// shared/context-menu.js — generic context-menu framework exposed on
// window.zddc.menu. Built so every ZDDC tool can drop a right-click
// menu (or any programmatically-opened menu) onto its UI without
// shipping its own implementation.
//
// API:
// window.zddc.menu.open({ x, y, items, context })
// window.zddc.menu.close()
//
// `items` is an array (or a function returning an array, evaluated
// against `context` at open-time). Each entry is one of:
// { label, action, icon?, accel?, disabled?, visible?, danger? }
// — a normal menu item; `action(ctx)` fires on click/Enter.
// { label, checked, action, ... }
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
// a ✓ in the gutter when truthy.
// { label, items, ... }
// — submenu; `items` may itself be an array or fn(ctx).
// { separator: true }
// — horizontal divider. Leading/trailing/duplicate separators
// are collapsed automatically so callers can build items
// conditionally without managing dividers.
//
// Any of `label`, `checked`, `visible`, `disabled`, and `items` may
// be a function — each is invoked with the context object so callers
// can render fully context-aware menus from a single declarative
// config.
//
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
// submenu, ArrowLeft / Escape backs up one level (or closes if
// already at the root), Enter / Space activates. Click-outside,
// window blur, scroll, and resize all dismiss.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.menu) return;
var SUBMENU_HOVER_MS = 180;
// Open menu stack — index 0 is the root, deeper entries are
// nested submenus. Each frame: { el, depth, parentRow? }.
var stack = [];
var rootContext = null;
var submenuTimer = null;
function resolve(val, ctx) {
return typeof val === 'function' ? val(ctx) : val;
}
function close() {
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
for (var i = 0; i < stack.length; i++) {
var fr = stack[i];
if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
}
stack = [];
rootContext = null;
document.removeEventListener('mousedown', onDocMouseDown, true);
document.removeEventListener('keydown', onDocKeyDown, true);
// blur is bound WITHOUT capture so we only react to the window
// itself losing focus — capturing would also fire when any
// inner element blurs (which happens every time the user moves
// the mouse between menu rows, since hover focuses the row).
window.removeEventListener('blur', close);
window.removeEventListener('resize', close, true);
window.removeEventListener('scroll', onDocScroll, true);
}
function open(opts) {
opts = opts || {};
close();
rootContext = opts.context || {};
var items = resolve(opts.items, rootContext) || [];
var el = buildMenu(items, rootContext, 0);
document.body.appendChild(el);
position(el, opts.x || 0, opts.y || 0, null);
stack.push({ el: el, depth: 0 });
document.addEventListener('mousedown', onDocMouseDown, true);
document.addEventListener('keydown', onDocKeyDown, true);
window.addEventListener('blur', close);
window.addEventListener('resize', close, true);
window.addEventListener('scroll', onDocScroll, true);
focusFirst(el);
}
// ── Building ─────────────────────────────────────────────────────────
function collapseSeparators(items) {
var out = [];
for (var i = 0; i < items.length; i++) {
var it = items[i];
if (it && it.separator) {
if (out.length === 0) continue;
if (out[out.length - 1].separator) continue;
out.push(it);
} else if (it) {
out.push(it);
}
}
while (out.length && out[out.length - 1].separator) out.pop();
return out;
}
function buildMenu(items, ctx, depth) {
var menu = document.createElement('div');
menu.className = 'zddc-menu';
menu.setAttribute('role', 'menu');
menu.dataset.depth = String(depth);
// Suppress the native context menu over our own menu.
menu.addEventListener('contextmenu', function (e) { e.preventDefault(); });
var filtered = items.filter(function (it) {
if (!it) return false;
if (it.separator) return true;
if ('visible' in it && !resolve(it.visible, ctx)) return false;
return true;
});
var pruned = collapseSeparators(filtered);
for (var i = 0; i < pruned.length; i++) {
menu.appendChild(buildRow(pruned[i], ctx, depth));
}
return menu;
}
function buildRow(item, ctx, depth) {
if (item.separator) {
var sep = document.createElement('div');
sep.className = 'zddc-menu__sep';
sep.setAttribute('role', 'separator');
return sep;
}
var hasSub = !!item.items;
var isToggle = ('checked' in item);
var disabled = 'disabled' in item ? !!resolve(item.disabled, ctx) : false;
var row = document.createElement('div');
row.className = 'zddc-menu__item';
if (item.danger) row.classList.add('zddc-menu__item--danger');
if (hasSub) row.classList.add('zddc-menu__item--has-sub');
if (disabled) {
row.classList.add('is-disabled');
row.setAttribute('aria-disabled', 'true');
}
row.setAttribute('role',
hasSub ? 'menuitem'
: (isToggle ? 'menuitemcheckbox' : 'menuitem'));
row.tabIndex = -1;
// Check gutter — present on every row so columns align.
var check = document.createElement('span');
check.className = 'zddc-menu__check';
if (isToggle) {
var on = !!resolve(item.checked, ctx);
if (on) {
check.textContent = '✓';
row.classList.add('is-checked');
row.setAttribute('aria-checked', 'true');
} else {
row.setAttribute('aria-checked', 'false');
}
}
row.appendChild(check);
// Icon column.
var icon = document.createElement('span');
icon.className = 'zddc-menu__icon';
if (item.icon) icon.textContent = item.icon;
row.appendChild(icon);
// Label.
var label = document.createElement('span');
label.className = 'zddc-menu__label';
label.textContent = String(resolve(item.label, ctx) || '');
row.appendChild(label);
// Accelerator hint (visual only; no binding).
var accel = document.createElement('span');
accel.className = 'zddc-menu__accel';
if (item.accel) accel.textContent = item.accel;
row.appendChild(accel);
// Submenu arrow.
var arrow = document.createElement('span');
arrow.className = 'zddc-menu__arrow';
if (hasSub) arrow.textContent = '▸';
row.appendChild(arrow);
if (!disabled) {
row.addEventListener('mouseenter', function () {
// Hovering any row in a menu collapses deeper menus
// (so traversing siblings closes a previously-opened
// submenu) and re-focuses this row for keyboard nav.
closeBelow(depth);
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
if (hasSub) {
submenuTimer = setTimeout(function () {
openSubmenu(row, item, ctx, depth + 1, false);
}, SUBMENU_HOVER_MS);
}
try { row.focus({ preventScroll: true }); } catch (_e) { row.focus(); }
});
row.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
if (hasSub) {
openSubmenu(row, item, ctx, depth + 1, true);
return;
}
activate(item, ctx);
});
}
return row;
}
function activate(item, ctx) {
try {
if (typeof item.action === 'function') item.action(ctx);
} finally {
close();
}
}
function openSubmenu(parentRow, parentItem, ctx, depth, takeFocus) {
closeBelow(depth - 1);
var items = resolve(parentItem.items, ctx) || [];
var el = buildMenu(items, ctx, depth);
document.body.appendChild(el);
var rect = parentRow.getBoundingClientRect();
// Slight overlap so pointer-cross feels continuous.
position(el, rect.right - 2, rect.top - 4, parentRow);
stack.push({ el: el, depth: depth, parentRow: parentRow });
if (takeFocus) focusFirst(el);
}
function closeBelow(depth) {
while (stack.length && stack[stack.length - 1].depth > depth) {
var fr = stack.pop();
if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
}
}
// ── Positioning ──────────────────────────────────────────────────────
function position(el, x, y, parentRow) {
// Fixed so we ignore document scroll; measure after layout.
el.style.position = 'fixed';
el.style.left = '0px';
el.style.top = '0px';
el.style.visibility = 'hidden';
var rect = el.getBoundingClientRect();
var w = rect.width;
var h = rect.height;
var vw = window.innerWidth;
var vh = window.innerHeight;
var leftX = x;
if (leftX + w > vw - 4) {
if (parentRow) {
var pr = parentRow.getBoundingClientRect();
leftX = pr.left - w + 2; // flip submenu to the left
} else {
leftX = Math.max(4, x - w); // flip root menu left of cursor
}
}
if (leftX < 4) leftX = 4;
var topY = y;
if (topY + h > vh - 4) topY = Math.max(4, vh - h - 4);
if (topY < 4) topY = 4;
el.style.left = leftX + 'px';
el.style.top = topY + 'px';
el.style.visibility = '';
}
// ── Focus + keyboard ─────────────────────────────────────────────────
function focusable(menuEl) {
return Array.prototype.slice.call(
menuEl.querySelectorAll('.zddc-menu__item:not(.is-disabled)'));
}
function focusFirst(menuEl) {
var items = focusable(menuEl);
if (items.length) {
try { items[0].focus({ preventScroll: true }); }
catch (_e) { items[0].focus(); }
}
}
function onDocMouseDown(e) {
for (var i = 0; i < stack.length; i++) {
if (stack[i].el.contains(e.target)) return;
}
close();
}
// Scroll listener uses capture so scrolls inside any element (the
// tree pane, the document, etc.) dismiss the menu — its position
// is fixed and would otherwise hang over stale content. Scrolls
// that originate inside the menu itself (a future tall submenu)
// are ignored.
function onDocScroll(e) {
var t = e.target;
for (var i = 0; i < stack.length; i++) {
if (stack[i].el === t || (t && t.nodeType === 1 && stack[i].el.contains(t))) {
return;
}
}
close();
}
function onDocKeyDown(e) {
if (!stack.length) return;
var top = stack[stack.length - 1];
var items = focusable(top.el);
var active = document.activeElement;
var idx = items.indexOf(active);
switch (e.key) {
case 'Escape':
e.preventDefault();
if (stack.length > 1) {
var fr = stack.pop();
if (fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
if (fr.parentRow) fr.parentRow.focus();
} else {
close();
}
return;
case 'ArrowDown':
e.preventDefault();
if (!items.length) return;
items[idx < 0 ? 0 : (idx + 1) % items.length].focus();
return;
case 'ArrowUp':
e.preventDefault();
if (!items.length) return;
items[idx < 0 ? items.length - 1
: (idx - 1 + items.length) % items.length].focus();
return;
case 'Home':
e.preventDefault();
if (items.length) items[0].focus();
return;
case 'End':
e.preventDefault();
if (items.length) items[items.length - 1].focus();
return;
case 'ArrowRight':
if (active && active.classList.contains('zddc-menu__item--has-sub')) {
e.preventDefault();
active.click();
}
return;
case 'ArrowLeft':
if (stack.length > 1) {
e.preventDefault();
var fr2 = stack.pop();
if (fr2.el.parentNode) fr2.el.parentNode.removeChild(fr2.el);
if (fr2.parentRow) fr2.parentRow.focus();
}
return;
case 'Enter':
case ' ':
if (active) {
e.preventDefault();
active.click();
}
return;
}
}
window.zddc.menu = { open: open, close: close };
})();

122
shared/elevation.css Normal file
View file

@ -0,0 +1,122 @@
/* shared/elevation.css admin-elevation toggle in the tool header.
Renders only for users with admin scope (handled by elevation.js;
the placeholder is `.hidden` by default). When visible, sits left
of the theme button sudo-style affordance for opting into admin
powers. */
.elevation-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--text-muted);
user-select: none;
cursor: pointer;
padding: 0.15rem 0.45rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.elevation-toggle:hover {
background: var(--bg-hover);
border-color: var(--border-dark);
}
.elevation-toggle input[type="checkbox"] {
margin: 0;
cursor: pointer;
accent-color: var(--danger);
}
.elevation-toggle__label {
cursor: pointer;
letter-spacing: 0.02em;
}
/* Active state when elevation is ON, the toggle reads as "armed"
so the user can't miss that admin powers are currently live.
:has(:checked) lets us style the wrapper based on the inner
checkbox without JS. */
.elevation-toggle:has(input:checked) {
background: rgba(220, 53, 69, 0.12);
border-color: var(--danger);
color: var(--danger);
font-weight: 600;
}
/* Page-wide chrome when admin mode is active. The toggle alone is
easy to miss; these add an inescapable visual cue:
1. Thin red border around the entire viewport peripheral-
vision reminder regardless of which tool / scroll position.
2. Sticky banner across the top with a one-click "Drop admin"
button so the user can disarm without hunting for the toggle.
Both rendered ONLY when the zddc-elevate cookie is set; the
shared/elevation.js init() syncs the body class on every page
load and tears it down when elevation is cleared.
Frame uses fixed positioning + pointer-events:none so it doesn't
reflow content or steal clicks. An inset outline on <body> was
tried first but overdrew content in tools whose root layout butts
right up to the viewport edge (browse split-pane, archive grid). */
body.is-elevated::after {
content: "";
position: fixed;
inset: 0;
border: 3px solid var(--danger, #dc3545);
pointer-events: none;
z-index: 9200; /* above banner (9100) so the frame paints on top */
}
.elevation-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.9rem;
background: rgba(220, 53, 69, 0.95);
color: #fff;
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.01em;
position: sticky;
top: 0;
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
.elevation-banner__dot {
width: 0.5rem;
height: 0.5rem;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
animation: elev-pulse 1.6s infinite;
flex-shrink: 0;
}
@keyframes elev-pulse {
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
}
.elevation-banner__msg {
flex: 1 1 auto;
}
.elevation-banner__off {
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.7);
color: #fff;
padding: 0.18rem 0.65rem;
border-radius: var(--radius, 4px);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
flex-shrink: 0;
}
.elevation-banner__off:hover {
background: rgba(255, 255, 255, 0.3);
}

148
shared/elevation.js Normal file
View file

@ -0,0 +1,148 @@
// shared/elevation.js — admin elevation toggle.
//
// Sudo-style model: admins behave as normal users by default; clicking
// the header toggle elevates the session so admin escape hatches (WORM
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
// State is carried in a `zddc-elevate=1` cookie that the server reads
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
// Only renders the toggle when /.profile/access reports the caller has
// some admin scope — a non-admin sees nothing, which keeps the chrome
// quiet for the common case. The toggle fades in once access loads so
// non-admins never even see the affordance flash.
//
// Click flow: set/clear the cookie, then reload the page so the server
// sees the new state on the next render. The reload is intentional —
// admin scaffolds in tool HTML are server-rendered for some tools, so
// a soft state flip on the client alone wouldn't reach those.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.elevation) return;
var COOKIE_NAME = 'zddc-elevate';
function isElevated() {
var parts = document.cookie.split(';');
for (var i = 0; i < parts.length; i++) {
var kv = parts[i].trim().split('=');
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
}
return false;
}
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
// shapes. Max-Age caps the elevation window so a forgotten
// tab doesn't leave admin powers active indefinitely (sudo's
// 5-minute precedent informs the number — 30 minutes is a
// reasonable trade between annoyance and exposure).
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
} else {
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
}
}
async function fetchAccess() {
try {
var resp = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
function render(host, elevated) {
host.classList.remove('hidden');
host.innerHTML =
'<input type="checkbox" id="elevation-checkbox"'
+ (elevated ? ' checked' : '') + '>'
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
+ 'Admin</label>';
var cb = host.querySelector('#elevation-checkbox');
cb.addEventListener('change', function () {
setElevated(cb.checked);
// Hard reload so server-rendered admin surfaces (profile
// page scaffolds, hidden-entry listings) catch up. URL
// and scroll state are preserved by the browser's normal
// back-forward cache rules.
window.location.reload();
});
}
// Page-wide affordances when elevation is active. The toggle alone
// is easy to miss — admin mode silently bypasses WORM and ACL
// restrictions, which produces surprising "I shouldn't have been
// able to do that" moments. A body class + a sticky banner with a
// one-click disable make the armed state unmistakable.
function applyArmedChrome(elevated) {
var b = document.body;
if (!b) return;
if (elevated) b.classList.add('is-elevated');
else b.classList.remove('is-elevated');
var banner = document.getElementById('elevation-banner');
if (elevated) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'elevation-banner';
banner.className = 'elevation-banner';
banner.setAttribute('role', 'alert');
banner.innerHTML =
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
+ '<span class="elevation-banner__msg">'
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
+ '</span>'
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
+ 'Drop admin'
+ '</button>';
document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off');
if (off) off.addEventListener('click', function () {
setElevated(false);
window.location.reload();
});
}
} else if (banner) {
banner.parentNode.removeChild(banner);
}
}
async function init() {
// Body chrome applies on every page load whether or not the
// header has a toggle slot — the banner needs to surface in
// tools / pages that don't host the toggle (e.g. iframed
// classifier inside browse's grid mode), so the user can't
// accidentally write through an elevated context elsewhere.
applyArmedChrome(isElevated());
var host = document.getElementById('elevation-toggle');
if (!host) return; // tool doesn't include the slot yet — no-op
var access = await fetchAccess();
if (!access) return; // anonymous / endpoint missing — no-op
// Surface ONLY for users who have admin authority somewhere.
// /.profile/access ships `can_elevate` as an elevation-
// INDEPENDENT signal — true for any user named in any admin
// list, regardless of current cookie state. The other flags
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
// authority and would be false for an un-elevated admin
// who hasn't toggled yet — so we can't gate on those.
if (!access.can_elevate) return;
render(host, isElevated());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})();

162
shared/icons.js Normal file
View file

@ -0,0 +1,162 @@
// shared/icons.js — minimal outline SVG sprite for ZDDC tools.
//
// Vendored from Lucide (https://lucide.dev, ISC). Only the 16
// file-type glyphs the browse tree maps to are bundled; total weight
// is ~4.5 KB of SVG path data. Each symbol viewBox is 0 0 24 24 with
// no stroke/fill attributes — those are applied at the call site via
// CSS so the icons inherit `currentColor` and tint with the theme.
//
// API:
// window.zddc.icons.inject() // mount sprite into <body> once
// window.zddc.icons.html('icon-foo') // → '<svg viewBox="0 0 24 24"><use href="#icon-foo"/></svg>'
// window.zddc.icons.ID // string set of valid symbol ids
//
// Callers concat html() output into innerHTML the same way they
// previously concat'd emoji glyphs. The injected sprite is hidden
// (`display:none` on the outer <svg>) so it costs zero layout.
//
// Why a sprite (rather than per-row inline paths): a hundred tree
// rows × 300 bytes of duplicated path data is 30 KB of churn on
// every re-render. With <use>, each row carries only a ~60-byte
// reference. The sprite is parsed once.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.icons) return;
// ── Sprite (Lucide outline glyphs, viewBox 24×24) ──────────────────────
// Concatenated from upstream lucide-static@1.16.0 SVGs; class/style
// attributes stripped. Order matches the icons-mapped block below
// so a diff against Lucide's source stays readable.
var SYMBOLS = ''
+ '<symbol id="icon-folder" viewBox="0 0 24 24">'
+ '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>'
+ '</symbol>'
+ '<symbol id="icon-folder-archive" viewBox="0 0 24 24">'
+ '<circle cx="15" cy="19" r="2"/>'
+ '<path d="M20.9 19.8A2 2 0 0 0 22 18V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h5.1"/>'
+ '<path d="M15 11v-1"/>'
+ '<path d="M15 17v-2"/>'
+ '</symbol>'
+ '<symbol id="icon-file" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '</symbol>'
+ '<symbol id="icon-file-text" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>'
+ '</symbol>'
+ '<symbol id="icon-file-image" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<circle cx="10" cy="12" r="2"/>'
+ '<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22"/>'
+ '</symbol>'
+ '<symbol id="icon-file-video" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M15.033 13.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56v-4.704a.645.645 0 0 1 .967-.56z"/>'
+ '</symbol>'
+ '<symbol id="icon-file-audio" viewBox="0 0 24 24">'
+ '<path d="M4 6.835V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-.343"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M2 19a2 2 0 0 1 4 0v1a2 2 0 0 1-4 0v-4a6 6 0 0 1 12 0v4a2 2 0 0 1-4 0v-1a2 2 0 0 1 4 0"/>'
+ '</symbol>'
+ '<symbol id="icon-file-archive" viewBox="0 0 24 24">'
+ '<path d="M13.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v11.5"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M8 12v-1"/><path d="M8 18v-2"/><path d="M8 7V6"/>'
+ '<circle cx="8" cy="20" r="2"/>'
+ '</symbol>'
+ '<symbol id="icon-file-spreadsheet" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/>'
+ '</symbol>'
+ '<symbol id="icon-file-code" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M10 12.5 8 15l2 2.5"/>'
+ '<path d="m14 12.5 2 2.5-2 2.5"/>'
+ '</symbol>'
+ '<symbol id="icon-file-cog" viewBox="0 0 24 24">'
+ '<path d="M15 8a1 1 0 0 1-1-1V2a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8z"/>'
+ '<path d="M20 8v12a2 2 0 0 1-2 2h-4.182"/>'
+ '<path d="m3.305 19.53.923-.382"/>'
+ '<path d="M4 10.592V4a2 2 0 0 1 2-2h8"/>'
+ '<path d="m4.228 16.852-.924-.383"/>'
+ '<path d="m5.852 15.228-.383-.923"/>'
+ '<path d="m5.852 20.772-.383.924"/>'
+ '<path d="m8.148 15.228.383-.923"/>'
+ '<path d="m8.53 21.696-.382-.924"/>'
+ '<path d="m9.773 16.852.922-.383"/>'
+ '<path d="m9.773 19.148.922.383"/>'
+ '<circle cx="7" cy="18" r="3"/>'
+ '</symbol>'
+ '<symbol id="icon-file-pen" viewBox="0 0 24 24">'
+ '<path d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z"/>'
+ '</symbol>'
+ '<symbol id="icon-book-marked" viewBox="0 0 24 24">'
+ '<path d="M10 2v8l3-3 3 3V2"/>'
+ '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/>'
+ '</symbol>'
+ '<symbol id="icon-presentation" viewBox="0 0 24 24">'
+ '<path d="M2 3h20"/>'
+ '<path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"/>'
+ '<path d="m7 21 5-5 5 5"/>'
+ '</symbol>'
+ '<symbol id="icon-ruler" viewBox="0 0 24 24">'
+ '<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/>'
+ '<path d="m14.5 12.5 2-2"/>'
+ '<path d="m11.5 9.5 2-2"/>'
+ '<path d="m8.5 6.5 2-2"/>'
+ '<path d="m17.5 15.5 2-2"/>'
+ '</symbol>'
+ '<symbol id="icon-globe" viewBox="0 0 24 24">'
+ '<circle cx="12" cy="12" r="10"/>'
+ '<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/>'
+ '<path d="M2 12h20"/>'
+ '</symbol>'
// Lightweight outline chevron — used by the tree as the
// expand/collapse affordance. The single glyph rotates 90°
// via CSS to indicate the expanded state, so we only ship
// one path instead of two.
+ '<symbol id="icon-chevron-right" viewBox="0 0 24 24">'
+ '<path d="m9 18 6-6-6-6"/>'
+ '</symbol>';
var injected = false;
function inject() {
if (injected) return;
// insertAdjacentHTML on body parses the SVG namespace correctly
// across all modern browsers (innerHTML on a <div> wrapper has
// historically tripped over <symbol> in some engines).
var sprite = '<svg xmlns="http://www.w3.org/2000/svg" '
+ 'aria-hidden="true" style="position:absolute;width:0;height:0;'
+ 'overflow:hidden" focusable="false">'
+ SYMBOLS
+ '</svg>';
if (document.body) {
document.body.insertAdjacentHTML('afterbegin', sprite);
injected = true;
} else {
document.addEventListener('DOMContentLoaded', inject, { once: true });
}
}
// Produces the per-row markup callers concat into innerHTML.
// Bundles the size + stroke defaults inline so the SVG renders
// correctly even before the page CSS runs (e.g. mid-paint).
function html(symbolId) {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" '
+ 'aria-hidden="true"><use href="#' + symbolId + '"/></svg>';
}
window.zddc.icons = { inject: inject, html: html };
})();

View file

@ -1,56 +0,0 @@
/* shared/nav.css lateral project-stage strip paired with shared/nav.js.
Sits as a sibling immediately under .app-header (mounted by JS).
Rendered only in online mode when a project segment is in the URL. */
.zddc-stage-strip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
line-height: 1.3;
flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
}
.zddc-stage-strip__project {
color: var(--text);
font-weight: 600;
margin-right: 0.15rem;
}
.zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
}

View file

@ -1,204 +0,0 @@
// shared/nav.js — lateral navigation strip across the project's
// cascade-declared stages. Mounted as a sibling of <header class="app-
// header"> on DOMContentLoaded, hydrated from the project root's
// directory listing.
//
// Stage discovery is cascade-driven (Phase 4c): fetch the project
// root's JSON listing, filter to entries with `declared: true`
// (server stamps these from the .zddc cascade's paths: tree), and
// render in canonical workflow order with display_name overrides
// honored. An operator who edits the project's .zddc paths: to add
// a new declared child sees it in the strip; one who removes a
// canonical entry sees the strip drop it.
//
// When the fetch fails (offline / no-server / file://), the strip
// falls back to the hardcoded four-stage list so existing
// deployments don't lose chrome. Hardcoded labels in this file are
// the LAST resort — the cascade is the source of truth in normal
// operation.
//
// Stage URLs follow the slash/no-slash convention: no slash opens
// the stage's default tool. Operators on non-standard layouts can
// override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
// Hardcoded fallback for offline / file:// / fetch-error contexts.
// Server-driven discovery (FETCH_STAGES below) is the normal path.
var FALLBACK_STAGES = [
{ name: 'archive', label: 'Archive' },
{ name: 'working', label: 'Working' },
{ name: 'staging', label: 'Staging' },
{ name: 'reviewing', label: 'Reviewing' },
];
// Canonical workflow order. Stages appearing in this list are
// rendered in this order; any extras the cascade declares are
// appended alphabetically.
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
for (var i = 0; i < stages.length; i++) {
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
return stages[i].name;
}
}
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function titleCase(s) {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function sortByWorkflow(stages) {
return stages.slice().sort(function (a, b) {
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
if (ia >= 0 && ib >= 0) return ia - ib;
if (ia >= 0) return -1;
if (ib >= 0) return 1;
return a.name.localeCompare(b.name);
});
}
// Fetch the project root listing and extract declared stage
// entries. Returns [] on any error so callers fall back to the
// hardcoded list. Each stage entry is {name, label} — label
// honors the cascade's display: override when present.
async function fetchStagesFor(project) {
try {
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) return [];
var data = await resp.json();
if (!Array.isArray(data)) return [];
var stages = [];
for (var i = 0; i < data.length; i++) {
var e = data[i];
if (!e || !e.declared || !e.is_dir) continue;
var bare = (e.name || '').replace(/\/$/, '');
if (!bare) continue;
stages.push({
name: bare,
label: e.display_name || titleCase(bare),
});
}
return sortByWorkflow(stages);
} catch (_e) {
return [];
}
}
function buildStrip(project, active, stages) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < stages.length; i++) {
var s = stages[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return; // already mounted
}
var active = currentStage(location.pathname, stages);
var strip = buildStrip(project, active, stages);
header.parentNode.insertBefore(strip, header);
}
async function mount() {
if (!shouldRender()) return;
var project = projectSegment(location.pathname);
if (!project) return;
// Render the hardcoded fallback immediately so the strip
// appears with no flicker, then upgrade to cascade-resolved
// stages once the fetch completes.
mountWith(project, FALLBACK_STAGES);
var fetched = await fetchStagesFor(project);
if (fetched.length === 0) return; // fetch failed → keep fallback
// Replace the strip with the cascade-driven one. Remove the
// existing strip first so mountWith re-mounts cleanly.
var existing = document.querySelector('.zddc-stage-strip');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
mountWith(project, fetched);
}
window.zddc.nav = {
mount: mount,
_projectSegment: projectSegment,
_currentStage: currentStage,
_fallbackStages: FALLBACK_STAGES,
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();

79
shared/vendor/codemirror-yaml.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
shared/vendor/codemirror-yaml.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -289,13 +289,33 @@
// Top-level helpers
// -----------------------------------------------------------------
// Strip a trailing tool .html (e.g. classifier.html) from a path
// to land on the "directory the tool was opened in".
// Resolve "the directory the tool was opened in" for the current
// page URL. Two URL shapes serve a tool:
//
// /…/<tool>.html — file URL; strip the trailing filename.
// /…/<dir>/ — trailing-slash directory URL; keep it.
// /…/<dir> — bare-directory URL served by the
// cascade's `default_tool` (e.g.
// archive/<party>/mdl serves the tables
// tool). Treat as the directory itself
// and append the missing slash.
//
// Discrimination is "does the last segment contain a dot?" — a dot
// is a reliable proxy for "looks like a file with an extension"
// since neither directory names nor default_tool paths contain
// them in this system.
function pathToDir(pathname) {
if (!pathname) return '/';
if (pathname.endsWith('/')) return pathname;
var slash = pathname.lastIndexOf('/');
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
var lastSeg = slash >= 0 ? pathname.substring(slash + 1) : pathname;
if (lastSeg.indexOf('.') !== -1) {
// Has an extension → looks like a file URL → strip the
// filename to land on the parent directory.
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
}
// No extension → the URL IS the directory; just close it.
return pathname + '/';
}
// Probe the server-mode root for the current page. Returns:
@ -375,9 +395,14 @@
// srcUrl points at the .md source on the server. fmt is one of
// "docx" | "html" | "pdf". The server response status maps to a
// friendly error message for the caller to surface (toast / status).
//
// URL grammar: srcUrl is the `<file>.md` source; the converted
// form lives at `<file>.<fmt>` (virtual file extension recognised
// by zddc-server's dispatcher). Replaces the older `?convert=`
// query form.
async function downloadConverted(srcUrl, fileName, fmt) {
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
{ credentials: 'same-origin' });
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
var resp = await fetch(convertUrl, { credentials: 'same-origin' });
if (!resp.ok) {
var msg;
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';

View file

@ -37,6 +37,7 @@
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
'REC',
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
'TBD',
];
var STATUS_SET = {};

View file

@ -21,8 +21,9 @@ concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/elevation.css" \
"../shared/logo.css" \
"../shared/context-menu.css" \
"css/table.css" \
"../form/css/form.css" \
> "$css_temp"
@ -38,9 +39,10 @@ concat_files \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"../shared/context-menu.js" \
"js/mode.js" \
"js/app.js" \
"js/context.js" \
@ -49,8 +51,11 @@ concat_files \
"js/sort.js" \
"js/editor.js" \
"js/undo.js" \
"js/add-row.js" \
"js/save.js" \
"js/row-ops.js" \
"js/clipboard.js" \
"js/export.js" \
"js/render.js" \
"js/main.js" \
"../form/js/app.js" \

View file

@ -103,6 +103,14 @@
background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02));
}
/* Minimum row height so a freshly-added row (every cell empty) stays
visible without this the row collapses to just cell padding and
looks like a thin divider line. Acts as a floor; rows with content
grow naturally to fit the text. */
.zddc-table__row {
height: 2.4em;
}
.zddc-table__row--readonly {
color: var(--color-text-muted);
}

109
tables/js/add-row.js Normal file
View file

@ -0,0 +1,109 @@
// add-row.js — inline new-row creation.
//
// Click "+ Add row" → append a draft row at the end of state.rows,
// focus its first editable cell, accumulate user typing into the
// drafts buffer like any other row. On row-blur, save.js detects the
// row.isNew flag and POSTs to <dir>/form.html (the form-create
// endpoint). The 201 response carries the new row's Location; we swap
// the synthetic url/yamlUrl for the real ones and the draft row
// becomes a normal saved row.
//
// Synthetic identity: each new row gets a temporary "__new-<N>" url
// so rowKey() returns something unique for selection + draft tracking.
// The temporary url is replaced after a successful POST. There is no
// "save on click" UX — the existing row-blur trigger is the save path,
// same as for edits.
(function (app) {
'use strict';
let _counter = 0;
function makeSyntheticKey() {
_counter += 1;
return '__new-' + _counter;
}
// Compute the form-create URL for the current page. Both
// /<dir>/table.html and /<dir>/ (default_tool: tables) shape work;
// /<dir>/form.html is the form handler's "create" endpoint either
// way (the form handler keys off the in-dir convention, not the
// visiting URL shape).
function formCreateUrl() {
let dir = (location.pathname || '/').replace(/\/table\.html$/, '/');
if (!dir.endsWith('/')) dir += '/';
return dir + 'form.html';
}
// Create-and-paint: the user-facing path.
function invoke() {
const key = createSilent();
if (typeof app.repaint === 'function') app.repaint();
focusNewRow(key);
}
// Push a draft row WITHOUT painting or focusing. Used by multi-row
// paste (clipboard.js) to create N rows in a single batch, with one
// paint at the end. Returns the synthetic url so callers can address
// the new row in their draft writes.
function createSilent() {
const key = makeSyntheticKey();
const draftRow = {
url: key,
yamlUrl: null,
data: {},
etag: null,
editable: true,
isNew: true,
};
if (!Array.isArray(app.state.rows)) {
app.state.rows = [];
}
app.state.rows.push(draftRow);
return key;
}
function focusNewRow(key) {
// After repaint, find the tr with our synthetic data-row-id and
// tell the editor to select its first cell. Filtering may have
// hidden the new row if a default filter excludes it; we accept
// that — clearing filters surfaces it.
const tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
const trs = tbody.querySelectorAll('tr');
for (let i = 0; i < trs.length; i++) {
if (trs[i].getAttribute('data-row-id') === key) {
const editor = app.modules.editor;
if (editor && typeof editor.setSelected === 'function') {
// Scroll into view so the user sees the new row.
trs[i].scrollIntoView({ block: 'nearest', behavior: 'auto' });
editor.setSelected(i, 0);
}
return;
}
}
}
// Cancel-new-row helper: drop the synthetic row entirely. Used when
// the user adds a row, makes no edits, and clicks Add again or
// navigates away — there's nothing to save and an empty draft just
// clutters the table. The save module calls this from row-blur when
// it sees a new row with no drafts.
function discardEmpty(rowId) {
const rows = app.state.rows || [];
for (let i = 0; i < rows.length; i++) {
if (rows[i].isNew && rows[i].url === rowId) {
rows.splice(i, 1);
if (typeof app.repaint === 'function') app.repaint();
return true;
}
}
return false;
}
app.modules.addRow = {
invoke: invoke,
createSilent: createSilent,
formCreateUrl: formCreateUrl,
discardEmpty: discardEmpty,
};
})(window.tablesApp);

View file

@ -119,17 +119,32 @@
// --- Apply paste --------------------------------------------------
function applyPaste(anchorRowIdx, anchorColIdx, grid) {
// grid is string[][]. Returns {applied: int, skipped: int}.
// grid is string[][]. Returns {applied: int, skipped: int, created: int}.
// When the paste extends past the last existing row, the
// add-row module creates new draft rows on the fly so an Excel
// copy lands as a complete data set, not a clipped one. Each
// new row will save on its own row-blur (POST to form-create).
const ed = editor();
const totalRows = visibleRowCount();
const cols = (app.context && app.context.columns) || [];
const totalCols = cols.length;
let applied = 0, skipped = 0;
const addRow = app.modules.addRow;
let applied = 0, skipped = 0, created = 0;
for (let r = 0; r < grid.length; r++) {
const dstR = anchorRowIdx + r;
if (dstR >= totalRows) { skipped += grid[r].length; continue; }
const row = rowDataAtIndex(dstR);
let row = null;
if (dstR < totalRows) {
row = rowDataAtIndex(dstR);
} else if (addRow && typeof addRow.createSilent === 'function') {
addRow.createSilent();
created++;
// After createSilent the new row is at the end of
// state.rows but the DOM hasn't repainted yet — pull
// straight from state.rows to address it.
const all = (app.state && app.state.rows) || [];
row = all[all.length - 1];
}
if (!row) { skipped += grid[r].length; continue; }
for (let c = 0; c < grid[r].length; c++) {
const dstC = anchorColIdx + c;
@ -141,7 +156,7 @@
applied++;
}
}
return { applied: applied, skipped: skipped };
return { applied: applied, skipped: skipped, created: created };
}
function visibleRowCount() {
@ -208,11 +223,15 @@
const result = applyPaste(r, c, grid);
// Trigger a re-paint so draft values display.
if (typeof app.repaint === 'function') app.repaint();
let msg = 'Pasted ' + result.applied + ' cell' + plural(result.applied);
if (result.created > 0) {
msg += ' into ' + result.created + ' new row' + plural(result.created);
}
if (result.skipped > 0) {
notifyToast(
'Pasted ' + result.applied + ' cell' + plural(result.applied) +
'; ' + result.skipped + ' dropped (out of bounds)'
);
msg += '; ' + result.skipped + ' dropped (out of bounds)';
}
if (result.created > 0 || result.skipped > 0) {
notifyToast(msg);
}
}

View file

@ -111,16 +111,31 @@
description: spec.description,
columns: spec.columns,
defaults: spec.defaults,
// addable defaults to true; tables can opt out with
// `addable: false` (used by project-rollup MDL/RSK where the
// party affiliation of a new row is ambiguous — add at the
// per-party path instead).
addable: spec.addable !== false,
rowSchema: rowSchema,
rows: rows
};
}
function tableNameFromUrl(pathname) {
// /<dir>/.../<rowsdir>/table.html → name is the rows-dir's
// basename.
const m = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
return m ? m[1] : null;
// Two URL shapes resolve to a table page:
// Form A — /<…>/<rowsdir>/table.html (legacy/explicit
// entry-point; the tool was opened via the
// literal file URL).
// Form B — /<…>/<rowsdir> or /<…>/<rowsdir>/ (served
// by the cascade's `default_tool: tables` at
// archive/<party>/mdl; the URL is the directory
// itself, no trailing filename).
// In both cases the table name is the rows-directory basename.
const a = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
if (a) return a[1];
const trimmed = String(pathname || '').replace(/\/$/, '');
const b = trimmed.match(/\/([^\/]+)$/);
return b ? b[1] : null;
}
function stripDotSlash(p) {
@ -198,11 +213,13 @@
return rows;
}
// Re-edit URL for one row. Page is at /<dir>/table.html; row file
// lives at /<dir>/<basename>.yaml; form re-edit URL is
// /<dir>/<basename>.yaml.html — same directory.
// Re-edit URL for one row. The page directory is the same
// directory the rows live in, regardless of which URL shape
// (Form A `…/table.html` vs Form B bare `…/<rowsdir>`) we were
// opened with — see tableNameFromUrl.
function rowEditUrl(rowFileName) {
const pageDir = location.pathname.replace(/\/table\.html$/, '/');
let pageDir = location.pathname.replace(/\/table\.html$/, '/');
if (!pageDir.endsWith('/')) pageDir += '/';
return pageDir + rowFileName + '.html';
}

79
tables/js/export.js Normal file
View file

@ -0,0 +1,79 @@
// export.js — CSV download of the current table view.
//
// Exports what the user sees: filter + sort applied, columns in the
// order declared by the spec. Values pass through util.formatCell so
// date / number / boolean cells match their on-screen rendering.
// RFC 4180 quoting (double-quote any cell with a comma, newline, or
// quote; escape inner quotes by doubling). UTF-8 BOM prepended so
// Excel detects the encoding without a manual import-wizard step.
(function (app) {
'use strict';
function csvEscape(value) {
if (value == null) return '';
const str = String(value);
if (/[",\r\n]/.test(str)) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
function buildCsv(rows, columns, util) {
const lines = [];
lines.push(columns.map(function (c) {
return csvEscape(c.title || c.field || '');
}).join(','));
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const cells = columns.map(function (c) {
const raw = util.resolveField(row.data, c.field);
return csvEscape(util.formatCell(raw, c.format));
});
lines.push(cells.join(','));
}
return lines.join('\r\n') + '\r\n';
}
function suggestFilename() {
const titleEl = document.getElementById('table-title');
const raw = (titleEl && titleEl.textContent) ? titleEl.textContent : 'table';
const base = raw.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'table';
const stamp = new Date().toISOString().slice(0, 10);
return base + '-' + stamp + '.csv';
}
function download(csv, filename) {
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
function invoke() {
const ctx = app.context || {};
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
if (columns.length === 0) {
return;
}
const state = app.state;
const util = app.modules.util;
const filtered = app.modules.filters.apply(state.rows, columns, state.filter, util.resolveField);
const sorted = app.modules.sort.apply(filtered, state.sort, columns, util);
const csv = buildCsv(sorted, columns, util);
download(csv, suggestFilename());
}
app.modules.exportCsv = {
invoke: invoke,
buildCsv: buildCsv,
csvEscape: csvEscape
};
})(window.tablesApp);

View file

@ -30,19 +30,56 @@
const countEl = document.getElementById('table-rowcount');
const clearBtn = document.getElementById('table-clear-filters');
const addRowBtn = document.getElementById('table-add-row');
const exportBtn = document.getElementById('table-export-csv');
// Add-row button: appends a draft row inline. Save fires on
// row-blur, which POSTs to <dir>/form.html and swaps the
// synthetic row id for the server's response. The button shows
// whenever the page is a real table view (http(s) + a table
// context loaded with columns) — the test-fixture inline-context
// harness opens tables.html directly with no URL shape, so we
// gate on having a column list AND running over http(s).
// Export CSV: client-side build of the current view (filtered +
// sorted columns + values). No server round-trip, no auth gate
// — the user already has the data on screen. Shown on every
// table that loaded with columns, regardless of HTTP/file://.
if (exportBtn) {
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
if (hasCols) {
exportBtn.hidden = false;
exportBtn.addEventListener('click', function () {
const exp = app.modules.exportCsv;
if (exp && typeof exp.invoke === 'function') {
exp.invoke();
}
});
}
}
// Add-row button: link to <name>.form.html, the form-system's
// empty-form URL for this table's row schema. POST creates a
// new submission and the server redirects to the row's edit
// URL. Hidden when we can't derive a table name from the
// pathname (e.g. inline-context test harness opening tables.html
// directly without a *.table.html URL).
if (addRowBtn) {
// Page is at <dir>/table.html; the row-creation form is at
// <dir>/form.html — same directory, just swap the basename.
if (/\/table\.html$/.test(location.pathname || '')) {
addRowBtn.href = 'form.html';
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
// ctx.addable === false suppresses the affordance entirely.
// Used by project-rollup tables where the row's party
// affiliation is ambiguous (add at the per-party path).
const allowAdd = ctx.addable !== false;
if (onHttp && hasCols && allowAdd) {
addRowBtn.hidden = false;
addRowBtn.removeAttribute('href');
addRowBtn.setAttribute('role', 'button');
addRowBtn.setAttribute('tabindex', '0');
addRowBtn.style.cursor = 'pointer';
const handleAdd = function (ev) {
ev.preventDefault();
const addRow = app.modules.addRow;
if (addRow && typeof addRow.invoke === 'function') {
addRow.invoke();
}
};
addRowBtn.addEventListener('click', handleAdd);
addRowBtn.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev);
});
}
}
@ -106,6 +143,12 @@
editor.setSelected(state.selected.row, state.selected.col, { noFocus: true });
}
}
// Row context menu re-attaches each paint — renderBody wipes
// the tbody, taking listeners with it.
const rowOps = app.modules.rowOps;
if (rowOps && typeof rowOps.attach === 'function') {
rowOps.attach();
}
// Re-apply Phase-3 dirty-row markers — tbody.innerHTML='' in
// renderBody wiped them.
const save = app.modules.save;

201
tables/js/row-ops.js Normal file
View file

@ -0,0 +1,201 @@
// row-ops.js — row-level operations (delete, future: duplicate,
// copy-to-table, etc.). Surfaced via a right-click context menu on
// table rows; the editor's selection state determines which row the
// action targets when the menu is invoked from the keyboard or from a
// future toolbar button.
//
// The shared context-menu primitive (window.zddc.menu) drives the
// rendering and keyboard behaviour. This module owns the menu spec
// and the action handlers.
(function (app) {
'use strict';
function findRowById(rowId) {
const all = (app.state && app.state.rows) || [];
for (let i = 0; i < all.length; i++) {
const editor = app.modules.editor;
const key = editor ? editor.rowKey(all[i]) : (all[i].url || '');
if (key === rowId) return all[i];
}
return null;
}
function removeRowFromState(row) {
const all = app.state.rows || [];
const idx = all.indexOf(row);
if (idx >= 0) all.splice(idx, 1);
// Drop any drafts keyed on the row's url.
if (app.state.drafts && row.url) {
delete app.state.drafts[row.url];
}
}
function rowDisplayName(row) {
if (!row) return '(unknown)';
if (row.isNew) return '(unsaved new row)';
if (row.yamlUrl) {
const m = row.yamlUrl.match(/[^/]+$/);
if (m) return m[0];
}
return row.url || '(row)';
}
async function deleteRow(rowId) {
const row = findRowById(rowId);
if (!row) return { status: 'noop' };
if (row.editable === false) return { status: 'readonly' };
// Unsaved new row: just drop it. Nothing to call.
if (row.isNew) {
removeRowFromState(row);
if (typeof app.repaint === 'function') app.repaint();
return { status: 'ok-local' };
}
if (!row.yamlUrl) {
// file:// or fixture context — nothing to delete server-side.
removeRowFromState(row);
if (typeof app.repaint === 'function') app.repaint();
return { status: 'ok-local' };
}
const ok = window.confirm('Delete row "' + rowDisplayName(row) + '"?\n\nThis cannot be undone.');
if (!ok) return { status: 'cancelled' };
const headers = {};
if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
let resp;
try {
resp = await fetch(row.yamlUrl, {
method: 'DELETE',
headers: headers,
credentials: 'same-origin'
});
} catch (err) {
window.alert('Delete failed: ' + (err && err.message ? err.message : err));
return { status: 'network-error', error: err };
}
if (resp.status === 200 || resp.status === 204) {
removeRowFromState(row);
if (typeof app.repaint === 'function') app.repaint();
return { status: 'ok' };
}
if (resp.status === 412) {
window.alert('Cannot delete: this row was changed since you loaded it. Reload to see the latest version.');
return { status: 'conflict' };
}
let body = '';
try { body = await resp.text(); } catch (_) { /* ignore */ }
window.alert('Delete failed (' + resp.status + '): ' + body);
return { status: 'http-error', code: resp.status };
}
// Returns the list of visible-row indices currently included in
// the editor's range selection. Empty when no range is active.
function rangeRowIndices() {
const range = app.state && app.state.range;
if (!range) return [];
const r0 = Math.min(range.anchor.row, range.focus.row);
const r1 = Math.max(range.anchor.row, range.focus.row);
const out = [];
for (let r = r0; r <= r1; r++) out.push(r);
return out;
}
// Map a visible-row index to its data-row-id (synthetic or real).
function rowIdAtIndex(idx) {
const trs = document.querySelectorAll('#table-root tbody > tr');
const tr = trs[idx];
return tr ? tr.getAttribute('data-row-id') : null;
}
async function deleteRows(rowIds) {
if (!rowIds || rowIds.length === 0) return { status: 'noop' };
if (rowIds.length === 1) return deleteRow(rowIds[0]);
const ok = window.confirm('Delete ' + rowIds.length + ' rows?\n\nThis cannot be undone.');
if (!ok) return { status: 'cancelled' };
// Walk back-to-front so removing by index from state.rows
// doesn't shift the indices of pending deletes.
let okCount = 0, failCount = 0;
for (let i = rowIds.length - 1; i >= 0; i--) {
const row = findRowById(rowIds[i]);
if (!row) continue;
if (row.isNew || !row.yamlUrl) {
removeRowFromState(row);
okCount++;
continue;
}
const headers = {};
if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
try {
const resp = await fetch(row.yamlUrl, {
method: 'DELETE',
headers: headers,
credentials: 'same-origin'
});
if (resp.status === 200 || resp.status === 204) {
removeRowFromState(row);
okCount++;
} else {
failCount++;
}
} catch (_err) {
failCount++;
}
}
if (typeof app.repaint === 'function') app.repaint();
if (failCount > 0) {
window.alert('Deleted ' + okCount + ' row(s); ' + failCount + ' failed.');
}
return { status: 'ok', deleted: okCount, failed: failCount };
}
function buildRowMenu(ctx) {
const rangeRows = ctx.rangeRowIds || [];
const inRange = rangeRows.length > 1 && rangeRows.indexOf(ctx.rowId) !== -1;
const targets = inRange ? rangeRows : [ctx.rowId];
const label = targets.length > 1 ? 'Delete ' + targets.length + ' rows' : 'Delete row';
return [
{
label: label,
icon: '🗑',
danger: true,
disabled: !ctx.row || ctx.row.editable === false,
action: function () {
if (targets.length > 1) deleteRows(targets);
else deleteRow(targets[0]);
}
}
];
}
function onRowContext(ev) {
const tr = ev.target.closest('tr[data-row-id]');
if (!tr) return;
const rowId = tr.getAttribute('data-row-id');
const row = findRowById(rowId);
if (!row) return;
ev.preventDefault();
const menu = window.zddc && window.zddc.menu;
if (!menu || typeof menu.open !== 'function') return;
const rangeRowIds = rangeRowIndices().map(rowIdAtIndex).filter(Boolean);
menu.open({
x: ev.clientX,
y: ev.clientY,
items: buildRowMenu({ row: row, rowId: rowId, rangeRowIds: rangeRowIds }),
context: { row: row, rowId: rowId, rangeRowIds: rangeRowIds }
});
}
function attach() {
const tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
tbody.addEventListener('contextmenu', onRowContext);
}
app.modules.rowOps = {
attach: attach,
deleteRow: deleteRow,
deleteRows: deleteRows,
};
})(window.tablesApp);

View file

@ -177,8 +177,21 @@
async function saveRow(rowId, opts) {
opts = opts || {};
const { row, drafts } = rowFromState(rowId);
if (!row || !drafts || Object.keys(drafts).length === 0) {
return { status: 'noop' };
if (!row) return { status: 'noop' };
const hasDrafts = drafts && Object.keys(drafts).length > 0;
// New (unsaved) rows: if the user added a row and then moved on
// without typing anything, drop the empty placeholder rather
// than POST an empty body that fails schema validation.
if (row.isNew && !hasDrafts) {
const addRow = app.modules.addRow;
if (addRow && typeof addRow.discardEmpty === 'function') {
addRow.discardEmpty(rowId);
}
return { status: 'discarded-empty' };
}
if (!hasDrafts) return { status: 'noop' };
if (row.isNew) {
return createRow(rowId, row, drafts, opts);
}
if (!row.yamlUrl) {
// file:// mode or rows from inline-context test fixtures
@ -281,6 +294,84 @@
return { status: 'http-error', code: resp.status };
}
// createRow handles the POST path for an isNew row. Body is YAML of
// the row's draft data (no row.data yet — it's a fresh row). Success
// is 201 + Location pointing at the new <id>.yaml; we swap the
// synthetic url/yamlUrl for the real ones and clear isNew so the
// row behaves like any other from this point on.
async function createRow(rowId, row, drafts, opts) {
const addRow = app.modules.addRow;
if (!addRow || typeof addRow.formCreateUrl !== 'function') {
setRowState(rowId, 'errored');
return { status: 'no-create-url' };
}
const createUrl = addRow.formCreateUrl();
const merged = mergeRow(row.data, drafts);
const yamlBody = window.jsyaml.dump(merged);
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
const fetchOpts = {
method: 'POST',
body: yamlBody,
headers: headers,
credentials: 'same-origin',
};
if (opts && opts.keepalive) fetchOpts.keepalive = true;
setRowState(rowId, 'saving');
let resp;
try {
resp = await fetch(createUrl, fetchOpts);
} catch (err) {
console.error('[tables] createRow network error', err);
setRowState(rowId, 'errored');
return { status: 'network-error', error: err };
}
if (resp.status === 201) {
// Server wrote the row. Body is {location, filename}; we
// also accept the Location header if the body isn't JSON.
let body = {};
try { body = await resp.json(); } catch (_) { /* ignore */ }
const location = body.location || resp.headers.get('Location') || '';
const newEtag = (resp.headers.get('ETag') || '').replace(/"/g, '');
row.yamlUrl = location;
row.url = location ? location + '.html' : row.url;
row.data = merged;
row.etag = newEtag || null;
row.isNew = false;
// Move the drafts entry (was keyed on the synthetic id) to
// the new url, then clear it (data has the merged values).
delete app.state.drafts[rowId];
clearCellInvalid(rowId);
setRowState(rowId, '');
const sb = document.getElementById('table-status');
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
// Re-paint so the row picks up its new data-row-id and any
// server-supplied default fields surface.
if (typeof app.repaint === 'function') app.repaint();
return { status: 'ok' };
}
if (resp.status === 422) {
let body = {};
try { body = await resp.json(); } catch (_) { /* ignore */ }
clearCellInvalid(rowId);
const errs = body.errors || [];
for (let i = 0; i < errs.length; i++) {
const e = errs[i];
const field = String(e.path || '').replace(/^\//, '').split('/')[0];
if (field) markCellInvalid(rowId, field, e.message || 'invalid');
}
setRowState(rowId, 'invalid');
return { status: 'invalid', errors: errs };
}
console.warn('[tables] createRow returned', resp.status);
setRowState(rowId, 'errored');
return { status: 'http-error', code: resp.status };
}
async function useMine(rowId) {
const { row, drafts } = rowFromState(rowId);
if (!row || !drafts) return;

View file

@ -26,6 +26,12 @@
</div>
</div>
<div class="header-right">
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div>
@ -41,6 +47,7 @@
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
</div>
<div class="table-toolbar__right">
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
</div>
</div>

View file

@ -1,91 +0,0 @@
// Tests for shared/nav.js — the lateral project-stage strip.
//
// The strip's render decision depends on location.protocol and
// location.pathname. file:// won't render at all (online-only). To
// exercise online behavior we spin up a tiny in-process HTTP server
// for this spec so the page can be served from http://127.0.0.1:<port>
// at arbitrary paths.
import { test, expect } from '@playwright/test';
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
const HTML_PATH = path.resolve('classifier/dist/classifier.html');
let server;
let baseUrl;
test.beforeAll(async () => {
const html = fs.readFileSync(HTML_PATH, 'utf8');
server = http.createServer((req, res) => {
// Serve the same classifier HTML at every path. The strip's
// detection logic uses location.pathname; the bytes don't have
// to vary.
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
baseUrl = `http://127.0.0.1:${port}`;
});
test.afterAll(async () => {
if (server) await new Promise(resolve => server.close(resolve));
});
test.describe('shared/nav.js stage strip', () => {
test('does NOT render at the deployment root', async ({ page }) => {
await page.goto(`${baseUrl}/index.html`, { waitUntil: 'load' });
await page.waitForSelector('.app-header', { timeout: 5000 });
await expect(page.locator('.zddc-stage-strip')).toHaveCount(0);
});
test('renders for <project>/archive.html with archive active', async ({ page }) => {
await page.goto(`${baseUrl}/projA/archive.html`, { waitUntil: 'load' });
const strip = page.locator('.zddc-stage-strip');
await expect(strip).toHaveCount(1);
await expect(strip.locator('.zddc-stage-strip__project')).toHaveText('projA');
const stages = await strip.locator('.zddc-stage').allTextContents();
expect(stages).toEqual(['Archive', 'Working', 'Staging', 'Reviewing']);
const active = strip.locator('.zddc-stage--active');
await expect(active).toHaveCount(1);
await expect(active).toHaveText('Archive');
await expect(active).toHaveAttribute('aria-current', 'page');
});
test('renders for <project>/working/foo/browse.html with working active', async ({ page }) => {
await page.goto(`${baseUrl}/projA/working/casey/browse.html`, { waitUntil: 'load' });
const active = page.locator('.zddc-stage-strip .zddc-stage--active');
await expect(active).toHaveText('Working');
});
test('stage links point to the canonical <project>/<stage>/ URLs', async ({ page }) => {
await page.goto(`${baseUrl}/projA/staging/`, { waitUntil: 'load' });
await page.waitForSelector('.zddc-stage-strip');
const links = await page.evaluate(() => {
const xs = document.querySelectorAll('.zddc-stage-strip .zddc-stage');
return Array.from(xs).map(a => ({ text: a.textContent, href: a.getAttribute('href') }));
});
expect(links).toEqual([
{ text: 'Archive', href: '/projA/archive' },
{ text: 'Working', href: '/projA/working' },
{ text: 'Staging', href: '/projA/staging' },
{ text: 'Reviewing', href: '/projA/reviewing' },
]);
});
test('mounts immediately above the app-header', async ({ page }) => {
await page.goto(`${baseUrl}/projA/archive.html`, { waitUntil: 'load' });
const prev = await page.evaluate(() => {
const h = document.querySelector('.app-header');
return h && h.previousElementSibling && h.previousElementSibling.className;
});
expect(prev).toContain('zddc-stage-strip');
});
});

View file

@ -25,7 +25,7 @@ concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/elevation.css" \
"../shared/logo.css" \
"css/base.css" \
"css/layout.css" \
@ -54,7 +54,6 @@ concat_files \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/preview-lib.js" \
"js/app.js" \
@ -87,6 +86,7 @@ concat_files \
"js/drop-zones.js" \
"js/focus.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"js/main.js" \
> "$js_raw"

View file

@ -43,7 +43,7 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
</div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action;
other tools have "Add Local Directory" here instead) -->
other tools have "Use Local Directory" here instead) -->
<div class="split-button" id="bottom-menu" hidden>
<button id="bottom-toggle" type="button" class="btn btn-primary split-button__toggle" data-no-disable="true" aria-haspopup="true" aria-expanded="false">&#x25BE;</button>
<button id="bottom-primary" type="button" class="btn btn-primary" data-no-disable="true">Publish</button>
@ -51,6 +51,12 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
</div>
</div>
<div class="header-right">
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button type="button" id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button type="button" id="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button>
</div>

View file

@ -425,29 +425,17 @@ for a level whose `acl.permissions` map matches the user.
The walk respects an **inherit fence** (see "The `inherit:` directive" below).
A level whose `acl.inherit: false` flag is set acts as a fence: ancestors above
it are invisible to descendants at-and-below the fence, both for grants and for
role lookups. In strict cascade mode the fence is ignored (NIST AC-6 invariant).
role lookups.
Implementation: `GrantedVerbsAtLevel` (`zddc/internal/zddc/acl.go`) computes the
per-level grant; `EffectiveVerbs` / `AllowedAction` walk the chain; the chain
itself is built by `EffectivePolicy` (`zddc/internal/zddc/cascade.go`); the
fence is computed by `PolicyChain.VisibleStart`.
#### Cascade mode
The leaf-overrides-ancestor behavior above is the default — it's the historical
commercial-tenant model where a subtree owner can grant access without
root-admin involvement. Federal deployments needing absolute parent denies
(NIST AC-6) start the server with `--cascade-mode=strict` (or
`ZDDC_CASCADE_MODE=strict`):
- **`delegated`** (default) — leaf grant overrides ancestor explicit-deny.
- **`strict`** — two-pass evaluation. First pass walks **root → leaf** for any
matching explicit-deny; if found, denied (subject to root-admin bypass).
Second pass is the leaf→root grant walk above. An ancestor explicit-deny
cannot be overridden by any leaf grant.
The mode is logged at startup and surfaced on `/.profile/config`. Subtree
`.zddc` files cannot change the mode — it's a deployment-wide policy.
The leaf-overrides-ancestor behaviour above is the in-process decider's only
rule. Federal deployments needing absolute parent denies (NIST AC-6) deploy
OPA with the bundled `access_federal.rego` (or their own Rego); see
"External OPA" below.
#### The `inherit:` directive
@ -484,14 +472,13 @@ Behaviour:
fence; `inherit: false` does not change WORM behaviour. See
"Canonical-folder behaviour via `.zddc` keys" below.
**Strict cascade mode IGNORES `inherit: false`.** NIST AC-6 requires
ancestor explicit-denies to be absolute, and the inherit directive
would let a leaf widen access an ancestor refused. Under
`--cascade-mode=strict` the directive has no effect (and the bundled
federal Rego at `--print-rego=federal` mirrors that rule). Operators
who need fence-style "reset" semantics in a federal-track deployment
should not use the directive — instead, restructure the tree so the
permissive ancestor rule never appears.
**Federal posture and `inherit: false`.** The bundled federal Rego at
`--print-rego=federal` makes ancestor explicit-denies absolute and
therefore ignores `inherit: false` (allowing a leaf to widen access an
ancestor refused would defeat NIST AC-6). Operators who need fence-
style "reset" semantics in a federal-track deployment should not use
the directive — instead, restructure the tree so the permissive
ancestor rule never appears.
The cascade tracer (`/.profile/effective-policy`) surfaces every
level's `inherit` flag and the `chain.visible_start` index so a
@ -939,13 +926,12 @@ have to redo the gap analysis from scratch.
(the upstream proxy still asserts the email; role membership is
evaluated server-side against the cascade).
- ~~**Least-privilege bounding** (NIST AC-6)~~*closed.* Operators
set `--cascade-mode=strict` (or `ZDDC_CASCADE_MODE=strict`) to
switch the in-process Go evaluator into the federal posture: any
ancestor explicit-deny is absolute and cannot be overridden by a
leaf grant. The mode is logged at startup and surfaced on
`/.profile/config`. The legacy commercial behavior is preserved as
the default `delegated` mode. External OPA (`ZDDC_OPA_URL`) remains
available for org-specific Rego on top of this.
deploy OPA (`ZDDC_OPA_URL`) pointed at the bundled federal Rego
(`zddc-server --print-rego=federal`) or their own variant. Under
that policy any ancestor explicit-deny is absolute and cannot be
overridden by a leaf grant. The in-process Go evaluator implements
only the commercial "leaf grants override ancestor denies" rule;
federal posture is exclusively the OPA path.
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
authoritative sources (PIV cert subject, IdP-managed identity). Required:
documented integration with at least one IdP supporting federal identity

View file

@ -87,24 +87,24 @@ func main() {
"addr", cfg.Addr,
"embedded_apps", embeddedVersionsForLog(embedded))
// Probe the container runtime for the MD→{docx,html,pdf} endpoint.
// Non-fatal: if the host has no podman/docker (or the remote
// socket is unreachable in sidecar mode), conversion requests
// return 503 and everything else keeps working. The probe installs
// the package-level Runner when an engine is found; the configured
// image refs are pulled lazily on first conversion via
// `--pull=missing` so there's no manual setup beyond installing
// podman or docker.
// Probe pandoc + chromium for the MD→{docx,html,pdf} endpoint.
// Non-fatal: if either binary isn't on PATH (operator running
// zddc-server outside the runtime image), conversion requests
// return 503 and everything else keeps working.
//
// SetRemoteURL + SetScratchDir must run BEFORE Probe so the probe
// can hit the sidecar socket when one is configured.
convert.SetImages(cfg.ConvertPandocImage, cfg.ConvertChromiumImage)
convert.SetRemoteURL(cfg.ConvertPodmanSocket)
// In the production runtime image, "pandoc" and "chromium-browser"
// on PATH resolve to wrapper scripts at /usr/local/bin/<name>
// that put the real binary into a cgroup v2 + bwrap sandbox
// before exec'ing it. zddc-server is unaware — it just sees
// the corresponding tool's behavior. The wrapper reads
// ZDDC_CONV_MEM_MAX, ZDDC_CONV_PIDS_MAX, and ZDDC_SCRATCH from
// the child env to drive cgroup setup + scratch-dir bind mount.
convert.SetBinaries(cfg.ConvertPandocBinary, cfg.ConvertChromiumBinary)
convert.SetScratchDir(cfg.ConvertScratchDir)
probeCtx, probeCancel := context.WithTimeout(context.Background(), 5*time.Second)
convert.Probe(probeCtx, cfg.ConvertEngine)
convert.Probe(probeCtx)
probeCancel()
convert.ConfigureLimits(cfg.ConvertMemMiB, cfg.ConvertCPUs, cfg.ConvertPIDs, cfg.ConvertTimeout)
convert.ConfigureLimits(cfg.ConvertMemMiB, cfg.ConvertPIDs, cfg.ConvertTimeout)
// Client mode short-circuit: when cfg.Upstream is set, this binary
// runs as a downstream proxy/cache/mirror rather than a master.
@ -191,10 +191,9 @@ func main() {
// http(s):// or unix:// values send each decision to an external
// OPA-compatible server (federal customers, custom Rego policies).
deciderCfg := policy.Config{
URL: cfg.OPAURL,
FailOpen: cfg.OPAFailOpen,
CacheTTL: cfg.OPACacheTTL,
CascadeMode: cfg.CascadeMode,
URL: cfg.OPAURL,
FailOpen: cfg.OPAFailOpen,
CacheTTL: cfg.OPACacheTTL,
}
// Translate "0" (operator opt-out) to "disable cache" (negative TTL is
// the policy package's sentinel for "skip the wrapper").
@ -217,7 +216,6 @@ func main() {
"mode", policyModeLabel(cfg.OPAURL),
"url", cfg.OPAURL,
"cache_ttl", cfg.OPACacheTTL,
"cascade_mode", cfg.CascadeMode,
"no_auth", cfg.NoAuth)
// Token store: bearer-token issuance and validation.
@ -243,7 +241,7 @@ func main() {
if useTLS {
inner = handler.HSTSMiddleware(inner)
}
inner = handler.AccessLogMiddleware(auditLogger, inner)
inner = handler.AccessLogMiddleware(cfg, auditLogger, inner)
inner = handler.ACLMiddleware(cfg, decider, tokens, inner)
mux.Handle("/", inner)
@ -369,7 +367,7 @@ func runClient(cfg config.Config) {
if useTLS {
inner = handler.HSTSMiddleware(inner)
}
inner = handler.AccessLogMiddleware(auditLogger, inner)
inner = handler.AccessLogMiddleware(cfg, auditLogger, inner)
mux := http.NewServeMux()
mux.Handle("/", inner)
@ -620,7 +618,7 @@ func serveSpecializedNoSlash(cfg config.Config, appsSrv *apps.Server, w http.Res
return false
}
chain, _ := zddc.EffectivePolicy(cfg.Root, dirAbs)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return true
}
@ -754,35 +752,24 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
// Project list API: GET / with Accept: application/json
if urlPath == "/" {
accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/json") {
handler.ServeProjectList(cfg, w, r)
return
}
}
// (Project list at GET / with Accept: application/json used to be
// served by a bespoke handler that returned a custom JSON shape.
// Removed in favour of routing /through the generic ServeDirectory:
// the directory listing now carries `title` per entry, so the
// landing page reads project names from the same shape every other
// listing has. Single canonical wire format > exception that
// reveals a special perspective.)
// Split path into segments
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
// Per-directory .zddc editor: <dir>/.zddc.html is a virtual URL
// served by the existing form-based editor (same handler that
// powers /.profile/zddc/edit?path=<dir>). Routed BEFORE the
// dot-prefix guard so the leaf segment isn't 404'd. The handler
// itself gates on hasAnyAdminScope; non-admins see 404.
if handler.IsZddcEditorRequest(urlPath) {
handler.ServeZddcEditorAtPath(cfg, w, r)
return
}
// Raw .zddc YAML view: <dir>/.zddc is reachable at every depth
// and returns the on-disk file's bytes (Content-Type: application/yaml)
// or — when no file exists — a synthetic placeholder body with a
// cascade summary so the user can see what's effective here.
// GET/HEAD only; writes go through the admin-gated .zddc.html
// form. Also carved out of the dot-prefix guard.
if handler.IsZddcFileRequest(urlPath) {
// cascade summary so the user can see what's effective here. The
// leaf is carved out of the dot-prefix guard below so GET/HEAD
// land here and PUT/DELETE/POST fall through to ServeFileAPI.
if handler.IsZddcFileRequest(urlPath) && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
handler.ServeZddcFile(cfg, w, r)
return
}
@ -809,7 +796,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// the ?hidden flag does NOT relax).
hiddenOK := r.URL.Query().Has("hidden") &&
(r.Method == http.MethodGet || r.Method == http.MethodHead)
for _, seg := range segments {
for i, seg := range segments {
if seg == "" {
continue
}
@ -823,6 +810,13 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
if seg == cfg.IndexPath {
continue
}
// `.zddc` is the only writable dot-prefixed file: GET/HEAD was
// handled by ServeZddcFile above; PUT/DELETE/POST fall through
// to ServeFileAPI. Only the LEAF segment carves through —
// `.zddc.d` and other intermediate dot dirs stay reserved.
if seg == handler.ZddcFileBasename && i == len(segments)-1 {
continue
}
if hiddenOK {
continue
}
@ -936,7 +930,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
if err != nil {
slog.Warn("ACL policy error on zip parent", "path", filepath.Dir(zipAbs), "err", err)
}
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -960,12 +954,25 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// other four apps are caught by the "stat fails → app HTML?" branch
// below, which only triggers when no concrete file is at the URL path.
//
// Gated by Accept: HTML requests get the landing tool, JSON requests
// fall through to ServeDirectory and get the generic listing (with
// per-entry titles via listing.FileInfo.Title). That keeps the wire
// protocol uniform — a JSON listing is a JSON listing whether you
// fetch /Project-1/ or /. Landing itself consumes the same shape.
//
// The landing page is intentionally public (no ACL gate). It's a
// project picker — the per-project ACL filtering done by
// fs.ListDirectory still hides projects an anonymous (or unauthorized)
// caller can't reach. See also handler.ServeDirectory's matching
// root-path bypass.
if appsSrv != nil && (urlPath == "/" || urlPath == "/index.html") {
//
// (Browsers normalize `https://host` → `https://host/`, so the
// no-slash vs slash distinction the user might want — picker on
// bare host, browse on trailing slash — can't be expressed: the
// HTTP request for both forms is `GET /`. The picker wins because
// it's the only meaningful entry point that scopes ACL per-project.)
if appsSrv != nil && (urlPath == "/" || urlPath == "/index.html") &&
!strings.Contains(r.Header.Get("Accept"), "application/json") {
realIndex := filepath.Join(cfg.Root, "index.html")
if _, err := os.Stat(realIndex); os.IsNotExist(err) {
chain, _ := zddc.EffectivePolicy(cfg.Root, cfg.Root)
@ -990,21 +997,28 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
// Default MDL spec fallback: archive/<party>/mdl.table.yaml
// and archive/<party>/mdl.form.yaml are served from embedded
// bytes when no operator file exists on disk. The table app
// fetches these client-side; the fallback lets a fresh
// project work out of the box.
// Default-spec fallback for the embedded table.yaml / form.yaml
// files served when no operator file exists on disk:
//
// <project>/archive/<party>/{mdl,rsk}/{table,form}.yaml
// <project>/archive/<party>/ssr.form.yaml
// <project>/{ssr,mdl,rsk}/{table,form}.yaml
//
// The table app fetches these client-side; the fallback lets
// a fresh project work out of the box. ACL gates against the
// chain at the request directory; for project-level virtual
// specs that chain is the project's, and for per-party paths
// it's the party's archive folder.
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if bytes, ok := handler.IsDefaultMdlSpec(cfg.Root, urlPath); ok {
if bytes, ok := handler.IsDefaultSpec(cfg.Root, urlPath); ok {
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-ZDDC-Source", "default-mdl-spec")
w.Header().Set("X-ZDDC-Source", "default-spec")
if r.Method == http.MethodHead {
return
}
@ -1012,18 +1026,66 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
}
// File doesn't exist at this path. If the URL matches one of
// the canonical app HTML names AND the request directory is
// one where that app is available (working/staging/incoming
// for classifier, staging for transmittal, anywhere for
// archive + browse, root only for landing), resolve via the
// apps subsystem.
// Virtual project-level table views (SSR / MDL rollup / RSK
// rollup). The virtual row URL doesn't exist on disk; the
// underlying canonical file lives in <project>/archive/<party>/.
// ACL evaluates against the canonical party-archive path so
// non-owners see the row read-only and party owners can edit.
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if vv := zddc.ResolveVirtualView(cfg.Root, urlPath); vv.Resolved && vv.Kind.IsRowKind() {
chain, _ := zddc.EffectivePolicy(cfg.Root, vv.PartyArchive)
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeVirtualViewRow(w, r, vv)
return
}
}
// File doesn't exist at this path. Before falling through to
// app-HTML routing or 404, check the two virtual-file-extension
// shapes that ZDDC exposes through the listing convention:
//
// <dir>.zip — subtree download (replaces `<dir>/?zip=1`)
// <file>.docx|html|pdf — MD-source conversion of sibling <file>.md
// (replaces `<file>.md?convert=<fmt>`)
//
// Both fire ONLY when stat failed at the requested URL — a
// real file always wins. The path-suffix form lets clients
// emit a plain <a href> + lets `curl -O` produce the right
// filename, no query-string handling required.
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if absDir, ok := handler.RecognizeVirtualSubtreeZip(cfg.Root, urlPath); ok {
chain, _ := zddc.EffectivePolicy(cfg.Root, absDir)
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeSubtreeZip(cfg, w, r, absDir)
return
}
if mdAbs, format, ok := handler.RecognizeVirtualConvert(cfg.Root, urlPath); ok {
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(mdAbs))
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeConverted(cfg, w, r, mdAbs, format, chain)
return
}
}
// If the URL matches one of the canonical app HTML names AND
// the request directory is one where that app is available
// (working/staging/incoming for classifier, staging for
// transmittal, anywhere for archive + browse, root only for
// landing), resolve via the apps subsystem.
if appsSrv != nil {
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
if apps.AppAvailableAt(cfg.Root, requestDir, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -1032,37 +1094,34 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
}
}
}
// Reviewing aggregator. <project>/reviewing/[<tracking>/] is
// a virtual view. The shape rule mirrors the other canonical
// folders (slash → browse, no-slash → default tool):
// - JSON request, any depth → aggregator listing (handler.ServeReviewing)
// - HTML, no slash → browse (default tool, via DefaultAppAt;
// browse hosts the markdown editor plugin)
// - HTML, with slash → browse.html (via ServeDirectory).
// browse fetches JSON which routes back
// through here to ServeReviewing.
// Depth-3 no-slash (reviewing/<tracking>) 302s to the slash form.
// Depth-2 no-slash (reviewing) falls through to the canonical-
// folder block below where DefaultAppAt routes to browse.
// reviewing/ is no longer a virtual aggregator — it's a normal
// directory under each project, populated by the Plan Review
// composite endpoint with physical workflow folders. Falls
// through to the canonical-folder block below.
//
// Virtual received/ window. <workflow>/received/[...] is a
// synthetic view onto the canonical received/<tracking>/
// declared by the workflow folder's .zddc.received_path.
// ResolveVirtualReceived validates the parent .zddc; on a
// match, route through the normal directory/file handlers,
// which swap the read source to the canonical based on the
// URL (ListDirectory and ServeFile via the absolute path).
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if proj, tracking, sidePath, ok := handler.IsReviewingPath(urlPath); ok {
if !strings.HasSuffix(urlPath, "/") {
if tracking != "" {
http.Redirect(w, r, urlPath+"/", http.StatusFound)
return
}
// Depth-2 no-slash falls through to canonical-folder block.
} else if strings.Contains(r.Header.Get("Accept"), "application/json") {
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Join(cfg.Root, proj))
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeReviewing(cfg, w, r, proj, tracking, sidePath)
if vr := zddc.ResolveVirtualReceived(cfg.Root, urlPath); vr.Resolved {
if strings.HasSuffix(urlPath, "/") {
handler.ServeDirectory(cfg, appsSrv, w, r)
return
}
// HTML trailing-slash falls through to canonical-folder
// block → ServeDirectory → embedded browse.html.
// File read — ACL-check against the canonical
// received's chain, then serve the canonical bytes
// while keeping the workflow URL in the address bar.
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(vr.ReceivedAbs))
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeFile(w, r, vr.ReceivedAbs)
return
}
}
// Cascade-declared paths: the .zddc cascade (embedded
@ -1083,12 +1142,9 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
(strings.HasSuffix(urlPath, "/") || filepath.Ext(urlPath) == "") &&
zddc.IsDeclaredPath(cfg.Root, absPath) {
if r.URL.Query().Has("zip") {
// Subtree download of a cascade-declared dir that
// doesn't exist on disk yet → an empty zip.
handler.ServeSubtreeZip(cfg, w, r, absPath)
return
}
// (Empty-subtree zip for cascade-declared paths is now
// handled by RecognizeVirtualSubtreeZip at the top of
// this branch — same handler, path-suffix grammar.)
if strings.HasSuffix(urlPath, "/") {
handler.ServeDirectory(cfg, appsSrv, w, r)
return
@ -1115,20 +1171,17 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
isRoot := urlPath == "/"
if !isRoot {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
// Subtree download: GET /dir/?zip=1 streams an application/zip of
// every readable file under this directory, ACL-filtered. Checked
// before the slash/no-slash routing so it works on both /dir and
// /dir/. Writes (PUT/DELETE/POST) never reach here — they're
// intercepted by the file API earlier — so this is GET/HEAD only.
if r.URL.Query().Has("zip") {
handler.ServeSubtreeZip(cfg, w, r, absPath)
return
}
// (Subtree downloads use the virtual `GET /dir.zip` URL —
// see RecognizeVirtualSubtreeZip handling at the top of the
// stat-fails branch above. Real directories stat-succeed
// here, so the virtual zip URL stat-fails at /dir.zip and
// matches there.)
// Slash/no-slash routing convention: trailing slash → the
// directory view (handler.ServeDirectory → DirTool, which
// resolves to browse by default; JSON requests always get the
@ -1170,20 +1223,15 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// Regular file: ACL on parent directory
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// MD→{docx,html,pdf} on-demand conversion. The endpoint reuses the
// source file's read policy (already gated above), so no separate
// ACL verb. Only .md sources are convertible; everything else falls
// through to the regular file serve.
if fmt := r.URL.Query().Get("convert"); fmt != "" &&
strings.HasSuffix(strings.ToLower(absPath), ".md") {
handler.ServeConverted(cfg, w, r, absPath, fmt, chain)
return
}
// (MD→{docx,html,pdf} on-demand conversion now lives at
// `GET /<dir>/<file>.{docx,html,pdf}` (virtual file URL,
// see RecognizeVirtualConvert). The .md source serves
// normally here.)
handler.ServeFile(w, r, absPath)
}

View file

@ -133,7 +133,7 @@ func TestDispatchAppsResolution(t *testing.T) {
// fake upstream. Allow all email patterns (anonymous) so the test
// doesn't have to set up email headers.
zf := zddc.ZddcFile{
ACL: zddc.ACLRules{Allow: []string{"*"}},
ACL: zddc.ACLRules{Permissions: map[string]string{"*": "rwcd"}},
Apps: map[string]string{
"archive": upstream.URL + "/archive_stable.html",
"transmittal": upstream.URL + "/transmittal_stable.html",
@ -224,7 +224,7 @@ var _ = apps.DefaultUpstream
func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n allow:\n - \"*@example.com\"\n deny: []\n")
"acl:\n permissions:\n \"*@example.com\": rwcd\n")
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
idx, err := archive.BuildIndex(root)
@ -289,6 +289,99 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
}
}
// TestDispatchZddcWriteRouting pins the dispatcher's .zddc routing:
// GET/HEAD lands on ServeZddcFile (which serves the YAML view or the
// virtual placeholder), and PUT/DELETE/POST falls through past the
// dot-prefix guard into ServeFileAPI. Before the .zddc-leaf carve-out,
// PUT/DELETE 405'd at ServeZddcFile (or 404'd at the dot-prefix guard)
// and the YAML editor's save flow had no live path.
func TestDispatchZddcWriteRouting(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"admins:\n - admin@example.com\nacl:\n permissions:\n \"*@example.com\": r\n")
mustMkdir(t, filepath.Join(root, "Project-A"))
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
MaxWriteBytes: 1 << 20,
}
ring := handler.NewLogRing(10)
withAuth := func(req *http.Request, email string, elevated bool) *http.Request {
ctx := handler.WithEmail(req.Context(), email)
ctx = handler.WithElevation(ctx, elevated)
return req.WithContext(ctx)
}
// GET routes to ServeZddcFile — serves YAML bytes for an authorised reader.
req := withAuth(httptest.NewRequest(http.MethodGet, "/.zddc", nil), "admin@example.com", true)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /.zddc: want 200, got %d body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") {
t.Errorf("GET /.zddc Content-Type = %q, want application/yaml*", ct)
}
// PUT must route to ServeFileAPI (not 405 from ServeZddcFile).
body := []byte("admins:\n - admin@example.com\n - extra@example.com\n")
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc", bytes.NewReader(body)), "admin@example.com", true)
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("PUT /.zddc: want 200/201, got %d body=%s", rec.Code, rec.Body.String())
}
// Read back via GET to confirm the write landed.
req = withAuth(httptest.NewRequest(http.MethodGet, "/.zddc", nil), "admin@example.com", true)
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if !strings.Contains(rec.Body.String(), "extra@example.com") {
t.Errorf("GET after PUT: body missing PUT bytes; got %q", rec.Body.String())
}
// Project-level .zddc that doesn't exist yet — PUT creates it.
req = withAuth(httptest.NewRequest(http.MethodPut, "/Project-A/.zddc", bytes.NewReader([]byte("title: A\n"))), "admin@example.com", true)
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("PUT /Project-A/.zddc: want 201, got %d body=%s", rec.Code, rec.Body.String())
}
// DELETE removes a .zddc.
req = withAuth(httptest.NewRequest(http.MethodDelete, "/Project-A/.zddc", nil), "admin@example.com", true)
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE /Project-A/.zddc: want 204, got %d body=%s", rec.Code, rec.Body.String())
}
// Non-admin elevated still 403 on PUT — the carve-out only opens
// the path past the segment guard; the decider gates ActionAdmin.
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc", bytes.NewReader([]byte("title: probe\n"))), "stranger@example.com", true)
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("PUT /.zddc by stranger: want 403, got %d body=%s", rec.Code, rec.Body.String())
}
// Intermediate .zddc.d segments stay reserved — only the LEAF .zddc
// is carved through. A PUT to /.zddc.d/foo must 404 at the guard.
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc.d/something", bytes.NewReader([]byte("x"))), "admin@example.com", true)
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("PUT /.zddc.d/something: want 404 (reserved segment), got %d", rec.Code)
}
}
// TestDispatchArchiveRedirect: any /<project>/<sub>/.../.archive/... is 302'd
// to the canonical /<project>/.archive/... so all tracking-number references
// converge on a single stable URL per (project, tracking) regardless of the
@ -296,7 +389,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
func TestDispatchArchiveRedirect(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n allow:\n - \"*\"\n")
"acl:\n permissions:\n \"*\": rwcd\n")
mustMkdir(t, filepath.Join(root, "ProjectA", "Working"))
idx, err := archive.BuildIndex(root)
@ -596,7 +689,7 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
func TestDispatchArchiveMethodGate(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n allow:\n - \"*\"\n")
"acl:\n permissions:\n \"*\": rwcd\n")
mustMkdir(t, filepath.Join(root, "ProjectA"))
idx, err := archive.BuildIndex(root)
@ -638,7 +731,7 @@ func TestDispatchArchiveMethodGate(t *testing.T) {
func TestDispatchCaseInsensitiveURL(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n allow:\n - \"*\"\n")
"acl:\n permissions:\n \"*\": rwcd\n")
mustMkdir(t, filepath.Join(root, "project-a", "working"))
mustWrite(t, filepath.Join(root, "project-a", "working", "note.md"), "lowercase note")
@ -843,79 +936,6 @@ func mustWrite(t *testing.T, path, body string) {
}
}
// TestDispatchSubtreeZip exercises the `?zip=1` subtree-download hook:
// it routes to handler.ServeSubtreeZip on both the slash and no-slash
// forms of a directory URL, and the dispatch's directory ACL gate
// still applies (a viewer with no read access to the directory gets
// 403 before the zip handler runs).
func TestDispatchSubtreeZip(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": r\n")
mustMkdir(t, filepath.Join(root, "Proj", "staging", "2025-01-15_AAA-EM-TRN-0001 (IFC) - T"))
mustWrite(t, filepath.Join(root, "Proj", "staging", "2025-01-15_AAA-EM-TRN-0001 (IFC) - T", "doc.txt"), "hello")
// A subtree only alice@x may read.
mustMkdir(t, filepath.Join(root, "Proj", "locked"))
mustWrite(t, filepath.Join(root, "Proj", "locked", ".zddc"),
"acl:\n inherit: false\n permissions:\n \"alice@x\": rwcda\n")
mustWrite(t, filepath.Join(root, "Proj", "locked", "secret.txt"), "s")
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"}
ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
do := func(path, email string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, email))
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
return rec
}
for _, path := range []string{"/Proj/staging/?zip=1", "/Proj/staging?zip=1"} {
rec := do(path, "bob@x")
if rec.Code != http.StatusOK {
t.Fatalf("%s status=%d, want 200", path, rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/zip" {
t.Errorf("%s Content-Type=%q", path, ct)
}
if rec.Header().Get("X-ZDDC-Source") != "subtree-zip" {
t.Errorf("%s missing X-ZDDC-Source", path)
}
body := rec.Body.Bytes()
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("%s body not a zip: %v", path, err)
}
var foundDoc bool
for _, f := range zr.File {
if strings.HasSuffix(f.Name, "/doc.txt") || f.Name == "staging/2025-01-15_AAA-EM-TRN-0001 (IFC) - T/doc.txt" {
foundDoc = true
}
}
if !foundDoc {
t.Errorf("%s zip missing doc.txt; entries=%d", path, len(zr.File))
}
}
// The dispatch's directory ACL gate runs before ServeSubtreeZip:
// bob@x can't read /Proj/locked at all → 403, no zip.
if rec := do("/Proj/locked/?zip=1", "bob@x"); rec.Code != http.StatusForbidden {
t.Errorf("bob@x /Proj/locked/?zip=1 status=%d, want 403", rec.Code)
}
// alice@x can → 200 zip.
if rec := do("/Proj/locked/?zip=1", "alice@x"); rec.Code != http.StatusOK {
t.Errorf("alice@x /Proj/locked/?zip=1 status=%d, want 200", rec.Code)
}
}
// TestGzhttpWrapper_CompressesLargeResponses asserts the gzhttp wrapper
// behavior we wire in main(): responses above MinSize get gzip-encoded
// when the client advertises Accept-Encoding: gzip; small responses
@ -994,86 +1014,3 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
})
}
// TestDispatchZddcEditorAtPath verifies the per-directory <dir>/.zddc.html
// virtual URL is recognised by the dispatcher and routed to the editor
// handler (carved out from the dot-prefix guard). Permission gate is
// hasAnyAdminScope; non-admins get 404.
func TestDispatchZddcEditorAtPath(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"admins:\n - root@example.com\n")
mustMkdir(t, filepath.Join(root, "Project", "working"))
mustWrite(t, filepath.Join(root, "Project", ".zddc"),
"title: Demo Project\n")
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
}
ring := handler.NewLogRing(10)
cases := []struct {
name string
path string
email string
wantStatus int
wantSubstr string
}{
{
"root admin opens project editor",
"/Project/.zddc.html", "root@example.com",
http.StatusOK, "Demo Project",
},
{
"root admin opens working/ editor (no .zddc on disk yet)",
"/Project/working/.zddc.html", "root@example.com",
http.StatusOK, ".zddc editor",
},
{
"root admin opens deployment-root editor",
"/.zddc.html", "root@example.com",
http.StatusOK, ".zddc editor",
},
{
"non-admin gets 404",
"/Project/.zddc.html", "stranger@example.com",
http.StatusNotFound, "",
},
{
"anonymous gets 404",
"/Project/.zddc.html", "",
http.StatusNotFound, "",
},
{
"missing directory gets 404",
"/Project/no-such-dir/.zddc.html", "root@example.com",
http.StatusNotFound, "",
},
{
"deeper than leaf rejected",
"/Project/.zddc.html/extra", "root@example.com",
http.StatusNotFound, "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, tc.email))
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s",
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantSubstr != "" && !strings.Contains(rec.Body.String(), tc.wantSubstr) {
t.Errorf("path=%q body missing %q", tc.path, tc.wantSubstr)
}
})
}
}

View file

@ -34,10 +34,16 @@ func NewServer(root string, cache *Cache, fetcher *Fetcher, buildVer string) *Se
}
// MatchAppHTML returns the canonical app name if requestPath matches a
// "<dir>/<app>.html" pattern for one of the five canonical apps, plus the
// directory (relative to root) the request is rooted at.
// "<dir>/<app>.html" pattern for one of the canonical apps, plus the
// directory (relative to root) the request is rooted at. The cmd/zddc-
// server dispatcher calls this when stat fails on a URL: a missing file
// that happens to look like `<dir>/archive.html` (or browse.html, etc.)
// resolves to the embedded app HTML for that directory — operators
// don't have to copy app HTML into every project.
//
// Special case: GET / and GET /index.html both resolve to landing.
// Special case: GET / and GET /index.html both resolve to landing — the
// only entry point that scopes ACL per-project, and the conventional
// place for a static-site index when an operator wants one.
func MatchAppHTML(requestPath string) (app string, requestDirRel string) {
if requestPath == "" || requestPath == "/" {
return "landing", ""

View file

@ -45,24 +45,21 @@ type Config struct {
OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable.
AppsPubKey string // --apps-pubkey / ZDDC_APPS_PUBKEY — path to the Ed25519 public key (PEM) used to verify Ed25519 signatures on URL-fetched apps: artifacts. Empty = URL apps disabled (only embedded + local-path apps work). Operators using zddc.varasys.io's canonical channels download pubkey.pem from there.
MaxWriteBytes int64 // --max-write-bytes / ZDDC_MAX_WRITE_BYTES — upper bound on PUT body size. Default 256 MiB. Per-request limit; rejected with 413.
CascadeMode string // --cascade-mode / ZDDC_CASCADE_MODE — "delegated" (default; leaf grants override ancestor denies) or "strict" (ancestor explicit-denies are absolute, NIST AC-6).
ArchiveRescanInterval time.Duration // --archive-rescan-interval / ZDDC_ARCHIVE_RESCAN_INTERVAL — periodic full re-walk of the archive index. Covers SMB/CIFS where inotify misses cross-client writes. Default 60s; 0 to disable.
// MD→{docx,html,pdf} conversion endpoint (see internal/convert).
// The server shells out to upstream pandoc + chromium container
// images via podman or docker, pulling each on first use via
// `--pull=missing`. No custom image build is required — only that
// podman or docker is on PATH and the configured image refs are
// reachable. If no runtime is found the endpoint serves 503.
ConvertPandocImage string // --convert-pandoc-image / ZDDC_CONVERT_PANDOC_IMAGE — image for MD→DOCX/HTML. Default docker.io/pandoc/latex:latest.
ConvertChromiumImage string // --convert-chromium-image / ZDDC_CONVERT_CHROMIUM_IMAGE — image for HTML→PDF. Default docker.io/zenika/alpine-chrome:latest.
ConvertEngine string // --convert-engine / ZDDC_CONVERT_ENGINE — override engine binary (default: probe for podman, then docker).
ConvertPodmanSocket string // --convert-podman-socket / ZDDC_CONVERT_PODMAN_SOCKET — when non-empty, run podman in remote mode against this Unix socket (e.g. unix:///var/run/podman/podman.sock). Used with the Kubernetes sidecar pattern so zddc-server's own pod stays unprivileged.
ConvertScratchDir string // --convert-scratch-dir / ZDDC_CONVERT_SCRATCH_DIR — directory used for per-conversion scratch (template + HTML/PDF intermediates). Must be a path the remote podman can see at the same path. Empty = use $TMPDIR (local-mode default).
ConvertMemMiB int // --convert-mem-mib / ZDDC_CONVERT_MEM_MIB — per-container memory cap in MiB. Default 512.
ConvertCPUs string // --convert-cpus / ZDDC_CONVERT_CPUS — per-container CPU limit. Default "2".
ConvertPIDs int // --convert-pids / ZDDC_CONVERT_PIDS — per-container PID limit. Default 100.
ConvertTimeout time.Duration // --convert-timeout / ZDDC_CONVERT_TIMEOUT — per-conversion wall clock. Default 30s.
// zddc-server exec's `pandoc` and `chromium-browser` directly.
// In the production runtime image those names resolve to wrapper
// scripts at /usr/local/bin/ that put the real binary into a
// cgroup v2 + bubblewrap sandbox before exec'ing it — see
// zddc/runtime.Containerfile + zddc/runtime/zddc-sandbox-exec.
// zddc-server is unaware of sandboxing; the image owns it.
ConvertPandocBinary string // --convert-pandoc-binary / ZDDC_CONVERT_PANDOC_BINARY — pandoc binary name (PATH-resolved) or absolute path. Default "pandoc". Resolves to the wrapper script in the runtime image.
ConvertChromiumBinary string // --convert-chromium-binary / ZDDC_CONVERT_CHROMIUM_BINARY — chromium binary name (PATH-resolved) or absolute path. Default "chromium-browser" (alpine); set to "chromium" on debian.
ConvertScratchDir string // --convert-scratch-dir / ZDDC_CONVERT_SCRATCH_DIR — directory used for per-conversion scratch (template + HTML/PDF intermediates). The wrapper bind-mounts this into the sandbox at the same path. Empty = use $TMPDIR.
ConvertMemMiB int // --convert-mem-mib / ZDDC_CONVERT_MEM_MIB — per-conversion memory cap in MiB (advisory; passed to the wrapper via ZDDC_CONV_MEM_MAX, applied as cgroup v2 memory.max). Default 1024.
ConvertPIDs int // --convert-pids / ZDDC_CONVERT_PIDS — per-conversion PID cap (passed to the wrapper via ZDDC_CONV_PIDS_MAX, applied as cgroup v2 pids.max). Default 256.
ConvertTimeout time.Duration // --convert-timeout / ZDDC_CONVERT_TIMEOUT — per-conversion wall clock (enforced in zddc-server via context.WithTimeout). Default 60s.
}
// ErrHelpRequested is returned by Load when --help is passed; the caller
@ -139,28 +136,20 @@ func Load(args []string) (Config, error) {
"Path to the Ed25519 public key (PEM) used to verify signatures on URL-fetched apps: artifacts. Empty (default) = URL-fetched apps refused; only embedded + local-path apps work. Download zddc.varasys.io/pubkey.pem if you use the canonical channels.")
maxWriteBytesFlag := fs.Int64("max-write-bytes", parseInt64OrDefault(os.Getenv("ZDDC_MAX_WRITE_BYTES"), 256*1024*1024),
"Maximum PUT body size in bytes for the file API. Default 256 MiB. Larger requests are rejected with 413.")
cascadeModeFlag := fs.String("cascade-mode", getEnv("ZDDC_CASCADE_MODE", "delegated"),
"ACL cascade evaluation mode: \"delegated\" (default — subtree allow can override ancestor deny) or \"strict\" (ancestor explicit-deny is absolute; NIST AC-6).")
archiveRescanIntervalFlag := fs.Duration("archive-rescan-interval", parseDurationOrDefault(os.Getenv("ZDDC_ARCHIVE_RESCAN_INTERVAL"), 60*time.Second),
"Periodic full re-walk of the archive index. Required on SMB/CIFS-backed roots where inotify misses cross-client writes. Default 60s; set 0 to disable.")
convertPandocImageFlag := fs.String("convert-pandoc-image", getEnv("ZDDC_CONVERT_PANDOC_IMAGE", "docker.io/pandoc/latex:latest"),
"Pandoc container image for MD→DOCX and MD→HTML. Pulled on first use via --pull=missing.")
convertChromiumImageFlag := fs.String("convert-chromium-image", getEnv("ZDDC_CONVERT_CHROMIUM_IMAGE", "docker.io/zenika/alpine-chrome:latest"),
"Headless Chromium container image for HTML→PDF. Pulled on first use via --pull=missing.")
convertEngineFlag := fs.String("convert-engine", os.Getenv("ZDDC_CONVERT_ENGINE"),
"Container engine override (default: probe for podman, then docker).")
convertPodmanSocketFlag := fs.String("convert-podman-socket", os.Getenv("ZDDC_CONVERT_PODMAN_SOCKET"),
"Run podman in remote mode against this Unix socket URL (e.g. unix:///var/run/podman/podman.sock). When set, the engine binary is invoked as `podman --remote --url=<this> run …`; the actual container creation happens in whatever process owns the socket (typically a podman-system-service sidecar). Empty = local mode.")
convertPandocBinaryFlag := fs.String("convert-pandoc-binary", getEnv("ZDDC_CONVERT_PANDOC_BINARY", "pandoc"),
"Pandoc binary name (PATH-resolved) or absolute path. Default \"pandoc\". In the runtime image this resolves to the wrapper at /usr/local/bin/pandoc which sandboxes the real binary.")
convertChromiumBinaryFlag := fs.String("convert-chromium-binary", getEnv("ZDDC_CONVERT_CHROMIUM_BINARY", "chromium-browser"),
"Chromium binary name (PATH-resolved) or absolute path. Default \"chromium-browser\" (alpine); set to \"chromium\" on debian/ubuntu.")
convertScratchDirFlag := fs.String("convert-scratch-dir", os.Getenv("ZDDC_CONVERT_SCRATCH_DIR"),
"Scratch directory for per-conversion intermediates (template, HTML, PDF). In remote mode this MUST be a path that the podman-service side can see at the same path — typically a shared emptyDir mounted at the same mountPath in both containers. Empty = use $TMPDIR (local mode).")
convertMemMiBFlag := fs.Int("convert-mem-mib", parseIntOrDefault(os.Getenv("ZDDC_CONVERT_MEM_MIB"), 512),
"Per-conversion container memory limit in MiB. Default 512.")
convertCPUsFlag := fs.String("convert-cpus", getEnv("ZDDC_CONVERT_CPUS", "2"),
"Per-conversion container CPU limit (passed to --cpus). Default 2.")
convertPIDsFlag := fs.Int("convert-pids", parseIntOrDefault(os.Getenv("ZDDC_CONVERT_PIDS"), 100),
"Per-conversion container PID limit. Default 100.")
convertTimeoutFlag := fs.Duration("convert-timeout", parseDurationOrDefault(os.Getenv("ZDDC_CONVERT_TIMEOUT"), 30*time.Second),
"Per-conversion wall-clock timeout. Default 30s.")
"Scratch directory for per-conversion intermediates (template, HTML, PDF). The runtime image's wrapper bind-mounts this into the sandbox at the same path. Empty = use $TMPDIR.")
convertMemMiBFlag := fs.Int("convert-mem-mib", parseIntOrDefault(os.Getenv("ZDDC_CONVERT_MEM_MIB"), 1024),
"Per-conversion memory limit in MiB (advisory; passed to the runtime-image wrapper via ZDDC_CONV_MEM_MAX, applied as cgroup v2 memory.max). Default 1024.")
convertPIDsFlag := fs.Int("convert-pids", parseIntOrDefault(os.Getenv("ZDDC_CONVERT_PIDS"), 256),
"Per-conversion PID limit (passed to the runtime-image wrapper via ZDDC_CONV_PIDS_MAX, applied as cgroup v2 pids.max). Default 256.")
convertTimeoutFlag := fs.Duration("convert-timeout", parseDurationOrDefault(os.Getenv("ZDDC_CONVERT_TIMEOUT"), 60*time.Second),
"Per-conversion wall-clock timeout (enforced in zddc-server via context.WithTimeout). Default 60s.")
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
"Tee structured access logs to this file (JSON, size-rotated). "+
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
@ -231,17 +220,13 @@ func Load(args []string) (Config, error) {
OPACacheTTL: *opaCacheTTLFlag,
AppsPubKey: *appsPubKeyFlag,
MaxWriteBytes: *maxWriteBytesFlag,
CascadeMode: *cascadeModeFlag,
ArchiveRescanInterval: *archiveRescanIntervalFlag,
ConvertPandocImage: *convertPandocImageFlag,
ConvertChromiumImage: *convertChromiumImageFlag,
ConvertEngine: *convertEngineFlag,
ConvertPodmanSocket: *convertPodmanSocketFlag,
ConvertScratchDir: *convertScratchDirFlag,
ConvertMemMiB: *convertMemMiBFlag,
ConvertCPUs: *convertCPUsFlag,
ConvertPIDs: *convertPIDsFlag,
ConvertTimeout: *convertTimeoutFlag,
ConvertPandocBinary: *convertPandocBinaryFlag,
ConvertChromiumBinary: *convertChromiumBinaryFlag,
ConvertScratchDir: *convertScratchDirFlag,
ConvertMemMiB: *convertMemMiBFlag,
ConvertPIDs: *convertPIDsFlag,
ConvertTimeout: *convertTimeoutFlag,
}
// Default Root to the current working directory.
@ -317,15 +302,6 @@ func Load(args []string) (Config, error) {
return Config{}, errors.New("--tls-cert and --tls-key must both be set or both be empty")
}
switch cfg.CascadeMode {
case "", "delegated":
cfg.CascadeMode = "delegated"
case "strict":
// ok
default:
return Config{}, fmt.Errorf("--cascade-mode must be \"delegated\" or \"strict\", got %q", cfg.CascadeMode)
}
// Plain HTTP mode trusts the email header from any client. Only safe
// behind an authenticating reverse proxy. Refuse to start when binding
// plain HTTP to a non-loopback interface unless the operator has

View file

@ -1,26 +1,29 @@
// Package convert turns a markdown source byte-buffer into DOCX, HTML,
// or PDF via two stock upstream container images: pandoc (default
// `docker.io/pandoc/latex:latest`) handles MD↔DOCX and MD→HTML, and
// a headless-chromium image (default `docker.io/zenika/alpine-chrome:latest`)
// handles HTML→PDF. No custom image build is required — the operator
// just needs `podman` or `docker` on PATH and the runner pulls each
// image on first use via `--pull=missing`.
// or PDF by exec'ing pandoc and chromium-browser. Each conversion runs
// inside a sandbox provided by the IMAGE — typically a wrapper script
// at /usr/local/bin/<binary> that puts the real binary into a cgroup
// v2 + bubblewrap sandbox before exec'ing it. See
// zddc/runtime.Containerfile for the production setup.
//
// zddc-server's Go code is unaware of sandboxing: it just exec's
// "pandoc" or "chromium-browser" and gets the corresponding tool's
// behavior back. Operators who want a different isolation strategy
// (firejail, systemd-nspawn, podman-run, raw exec for dev) replace
// the wrapper script in their image; the Go binary doesn't change.
//
// Public surface:
//
// ToDocx(ctx, source, meta) → []byte (DOCX bytes)
// ToHTML(ctx, source, meta) → []byte (standalone HTML)
// ToPDF (ctx, source, meta) → []byte (PDF, via HTML + chromium)
// ToDocx(ctx, source, meta) → []byte (DOCX bytes)
// ToHTML(ctx, source, meta) → []byte (standalone HTML)
// ToPDF (ctx, source, meta) → []byte (PDF, via HTML + chromium)
//
// Probe(ctx, override) → Capabilities (call once at startup)
// Available() → (Capabilities, bool)
// SetImages(pandoc, chromium) — install image refs from config
// Probe(ctx) → Capabilities (call once at startup)
// Available() → (Capabilities, bool)
// SetBinaries(pandoc, chromium) — install binary names from config
// SetScratchDir(dir) — install scratch root from config
//
// All three converters are safe for concurrent use; each call gets a
// fresh container. The pandoc image's entrypoint is `pandoc`, so the
// argv we pass after the image flows straight into pandoc. The
// alpine-chrome image's entrypoint is `chromium-browser`, so the argv
// flows into chromium-browser. No `sh -c` wrappers, no shell quoting.
// fresh scratch dir + (image-provided) sandbox.
//
// Metadata maps to the placeholders consumed by viewer-template.html.
// title/tracking_number/revision/status/is_draft typically come from
@ -55,42 +58,49 @@ type Metadata struct {
NoTOC bool
}
// Default images. Operator overrides via --convert-pandoc-image /
// --convert-chromium-image (see cmd/zddc-server). pandoc/latex carries
// TeX Live for native PDF too, so it's a superset of pandoc/core;
// operators wanting a slimmer footprint can switch to pandoc/core.
// Default binary names. The runtime image installs WRAPPER scripts at
// /usr/local/bin/pandoc and /usr/local/bin/chromium-browser (shadowing
// the real binaries in /usr/bin/) so these names resolve through the
// sandbox automatically. Operators running zddc-server outside the
// runtime image with raw binaries on PATH still get a working
// conversion endpoint — just without the per-call sandbox.
//
// Alpine's chromium package installs the binary as "chromium-browser";
// debian/ubuntu ships "chromium". Operators override via
// --convert-chromium-binary when the package on their image differs.
const (
DefaultPandocImage = "docker.io/pandoc/latex:latest"
DefaultChromiumImage = "docker.io/zenika/alpine-chrome:latest"
DefaultPandocBinary = "pandoc"
DefaultChromiumBinary = "chromium-browser"
)
var (
pandocImage atomic.Pointer[string]
chromiumImage atomic.Pointer[string]
scratchDir atomic.Pointer[string]
pandocBinary atomic.Pointer[string]
chromiumBinary atomic.Pointer[string]
scratchDir atomic.Pointer[string]
)
// SetImages installs the image refs used for subsequent ToDocx/ToHTML/
// ToPDF calls. Empty values keep the previous setting (or the
// DefaultPandocImage / DefaultChromiumImage constants on first call).
// Called from cmd/zddc-server/main.go after flag parsing.
func SetImages(pandoc, chromium string) {
// SetBinaries installs the binary names used by Probe/Run. Empty
// values keep the previous setting (or the DefaultPandocBinary /
// DefaultChromiumBinary constants on first call). The values are
// PATH-resolved names (e.g. "pandoc", "chromium-browser") or
// absolute paths. Called from cmd/zddc-server/main.go after flag
// parsing.
func SetBinaries(pandoc, chromium string) {
if pandoc != "" {
s := pandoc
pandocImage.Store(&s)
pandocBinary.Store(&s)
}
if chromium != "" {
s := chromium
chromiumImage.Store(&s)
chromiumBinary.Store(&s)
}
}
// SetScratchDir installs the host-side scratch root used for per-call
// intermediates (template, HTML, PDF). Empty means "use $TMPDIR" — the
// local-mode default. In remote mode this MUST be a path the podman-
// service sidecar can see at the same mountpoint, typically a shared
// emptyDir mounted at /work in both containers. Called from
// cmd/zddc-server/main.go after flag parsing.
// SetScratchDir installs the host-side scratch root used for
// per-call intermediates (template, HTML, PDF). Empty means "use
// $TMPDIR". The runtime-image wrapper bind-mounts the per-call
// scratch dir into its sandbox at the same path, so any path under
// this root works.
func SetScratchDir(dir string) {
s := dir
scratchDir.Store(&s)
@ -103,23 +113,24 @@ func currentScratchDir() string {
return ""
}
func currentPandocImage() string {
if p := pandocImage.Load(); p != nil && *p != "" {
func currentPandocBinary() string {
if p := pandocBinary.Load(); p != nil && *p != "" {
return *p
}
return DefaultPandocImage
return DefaultPandocBinary
}
func currentChromiumImage() string {
if p := chromiumImage.Load(); p != nil && *p != "" {
func currentChromiumBinary() string {
if p := chromiumBinary.Load(); p != nil && *p != "" {
return *p
}
return DefaultChromiumImage
return DefaultChromiumBinary
}
// ToDocx renders source markdown to DOCX bytes. One container run via
// the pandoc image. Caller passes the full file content (envelope +
// body); pandoc handles `markdown+yaml_metadata_block` natively.
// ToDocx renders source markdown to DOCX bytes. Single pandoc exec;
// no scratch dir needed (stdin → stdout). The caller passes the
// full file content (envelope + body); pandoc handles
// `markdown+yaml_metadata_block` natively.
func ToDocx(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
r := currentRunner()
if r == nil {
@ -132,13 +143,14 @@ func ToDocx(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
}
cmd = append(cmd, metadataArgs(m)...)
cmd = append(cmd, "-")
return r.Run(ctx, currentPandocImage(), source, nil, cmd)
return r.Run(ctx, currentPandocBinary(), source, "", cmd)
}
// ToHTML renders source markdown to standalone HTML using
// viewer-template.html. Embeds CSS + images via --embed-resources.
// Template + custom.css are bind-mounted into the container at /tpl
// from a per-call scratch dir.
// Template + custom.css live in a per-call scratch dir; the host
// path is passed via ZDDC_SCRATCH so the wrapper bind-mounts it
// into the sandbox at the same path.
func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
r := currentRunner()
if r == nil {
@ -150,6 +162,7 @@ func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
}
defer os.RemoveAll(scratch)
tplPath := filepath.Join(scratch, "viewer-template.html")
cmd := []string{
"--from=markdown+yaml_metadata_block",
"--to=html5",
@ -158,29 +171,27 @@ func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
"--section-divs",
"--id-prefix=",
"--html-q-tags",
"--template=/tpl/viewer-template.html",
"--template=" + tplPath,
}
if !m.NoTOC {
cmd = append(cmd, "--toc", "--toc-depth=6")
}
cmd = append(cmd, metadataArgs(m)...)
cmd = append(cmd, "--output=-", "-")
mounts := []string{scratch + ":/tpl:ro"}
return r.Run(ctx, currentPandocImage(), source, mounts, cmd)
return r.Run(ctx, currentPandocBinary(), source, scratch, cmd)
}
// ToPDF renders source markdown to PDF in two stages: pandoc produces
// HTML using viewer-template.html (stage 1, pandoc image), then headless
// Chromium prints that HTML to PDF (stage 2, chromium image). The
// two-stage choice preserves the print-media CSS already authored in
// viewer-template.html — pandoc's native --pdf-engine path uses LaTeX
// ToPDF renders source markdown to PDF in two stages: pandoc
// produces HTML using viewer-template.html (stage 1), then headless
// chromium prints that HTML to PDF (stage 2). The two-stage choice
// preserves the print-media CSS already authored in viewer-
// template.html — pandoc's native --pdf-engine path uses LaTeX
// which would bypass it entirely.
//
// Chromium runs from the alpine-chrome image whose entrypoint is
// `chromium-browser`; our cmd is the flag list passed straight to that
// binary. The host scratch dir is bind-mounted read-write at /pdf so
// chromium can write out.pdf and we read it back afterward.
// Both stages share a single per-call scratch dir: pandoc writes
// `in.html` and chromium reads it, then chromium writes `out.pdf`
// which the host reads back. The wrapper bind-mounts the scratch
// dir read-write into the sandbox at the same path.
func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
html, err := ToHTML(ctx, source, m)
if err != nil {
@ -205,17 +216,11 @@ func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
return nil, err
}
mounts := []string{scratch + ":/pdf:rw"}
// alpine-chrome's entrypoint is `chromium-browser`. --no-sandbox is
// required because the container drops CAP_SYS_ADMIN; the threat
// model is "malicious markdown drives chromium RCE", contained by
// --network=none + --cap-drop=ALL + --read-only + tmpfs.
//
// --disable-dev-shm-usage: without this, chromium tries to allocate
// shared memory under /dev/shm, which our --read-only container
// can't write to. The flag tells chromium to fall back to /tmp,
// which is a writable tmpfs (sized in runner.go). Standard fix for
// chromium-in-container; required by every CI/headless setup.
// --no-sandbox: the wrapper provides the sandbox; chromium's
// own setuid sandbox would conflict (and fails inside our
// user-namespace anyway). --disable-dev-shm-usage: chromium's
// shared-memory fallback writes to /dev/shm which our sandbox
// doesn't expose; redirect to /tmp (the wrapper's tmpfs).
cmd := []string{
"--headless",
"--disable-gpu",
@ -224,10 +229,10 @@ func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
"--user-data-dir=/tmp/chrome",
"--no-pdf-header-footer",
"--virtual-time-budget=10000",
"--print-to-pdf=/pdf/out.pdf",
"file:///pdf/in.html",
"--print-to-pdf=" + pdfPath,
"file://" + htmlPath,
}
if _, err := r.Run(ctx, currentChromiumImage(), nil, mounts, cmd); err != nil {
if _, err := r.Run(ctx, currentChromiumBinary(), nil, scratch, cmd); err != nil {
return nil, err
}
@ -237,7 +242,7 @@ func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
}
if len(out) < 4 || string(out[:4]) != "%PDF" {
return nil, &ConvertError{
Tool: "chromium",
Tool: currentChromiumBinary(),
ExitCode: 0,
Stderr: "chromium did not produce a valid PDF",
Cause: fmt.Errorf("invalid PDF magic in output (got %d bytes)", len(out)),
@ -246,9 +251,9 @@ func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
return out, nil
}
// metadataArgs renders Metadata into pandoc -V flags. Order is stable
// so test fixtures don't churn. Empty values are omitted (the template
// uses $if(...)$ blocks).
// metadataArgs renders Metadata into pandoc -V flags. Order is
// stable so test fixtures don't churn. Empty values are omitted
// (the template uses $if(...)$ blocks).
func metadataArgs(m Metadata) []string {
var out []string
add := func(k, v string) {

View file

@ -10,25 +10,25 @@ import (
)
// fakeRunner records the args it was invoked with and replays canned
// responses. Lets us assert the command lines + image refs without
// needing podman.
// responses. Lets us assert command lines + binary refs + scratch
// dirs without needing actual pandoc.
type fakeRunner struct {
mu sync.Mutex
calls [][]string
images []string
stdin [][]byte
mounts [][]string
resp []byte
err error
mu sync.Mutex
calls [][]string
binaries []string
stdin [][]byte
scratchDir []string
resp []byte
err error
}
func (f *fakeRunner) Run(_ context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error) {
func (f *fakeRunner) Run(_ context.Context, binary string, stdin []byte, scratchDir string, cmd []string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, append([]string(nil), cmd...))
f.images = append(f.images, image)
f.binaries = append(f.binaries, binary)
f.stdin = append(f.stdin, append([]byte(nil), stdin...))
f.mounts = append(f.mounts, append([]string(nil), mounts...))
f.scratchDir = append(f.scratchDir, scratchDir)
return f.resp, f.err
}
@ -38,14 +38,14 @@ func (f *fakeRunner) lastCall() (string, []string) {
if len(f.calls) == 0 {
return "", nil
}
return f.images[len(f.images)-1], f.calls[len(f.calls)-1]
return f.binaries[len(f.binaries)-1], f.calls[len(f.calls)-1]
}
func TestToDocx_UsesPandocImage(t *testing.T) {
func TestToDocx_UsesPandocBinary(t *testing.T) {
f := &fakeRunner{resp: []byte("FAKE-DOCX")}
InstallRunner(f)
t.Cleanup(func() { InstallRunner(nil) })
SetImages("docker.io/pandoc/latex:latest", "")
SetBinaries("pandoc", "chromium-browser")
out, err := ToDocx(context.Background(), []byte("# Hello\n"), Metadata{
Title: "Hello",
@ -57,9 +57,9 @@ func TestToDocx_UsesPandocImage(t *testing.T) {
if string(out) != "FAKE-DOCX" {
t.Errorf("unexpected output: %q", out)
}
image, call := f.lastCall()
if image != "docker.io/pandoc/latex:latest" {
t.Errorf("expected pandoc image, got %q", image)
binary, call := f.lastCall()
if binary != "pandoc" {
t.Errorf("expected pandoc binary, got %q", binary)
}
if !contains(call, "--to=docx") {
t.Errorf("missing --to=docx: %v", call)
@ -74,35 +74,40 @@ func TestToDocx_UsesPandocImage(t *testing.T) {
if call[len(call)-1] != "-" {
t.Errorf("expected stdin marker as last arg, got %q", call[len(call)-1])
}
// ToDocx is stdin → stdout — no scratch dir needed.
if f.scratchDir[len(f.scratchDir)-1] != "" {
t.Errorf("ToDocx should not need a scratch dir, got %q", f.scratchDir[len(f.scratchDir)-1])
}
}
func TestToHTML_UsesTemplateAndMountsScratch(t *testing.T) {
func TestToHTML_UsesTemplateFromScratchDir(t *testing.T) {
f := &fakeRunner{resp: []byte("<html>fake</html>")}
InstallRunner(f)
t.Cleanup(func() { InstallRunner(nil) })
SetImages("docker.io/pandoc/latex:latest", "")
SetBinaries("pandoc", "chromium-browser")
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"})
if err != nil {
t.Fatalf("ToHTML: %v", err)
}
image, call := f.lastCall()
if image != "docker.io/pandoc/latex:latest" {
t.Errorf("expected pandoc image, got %q", image)
binary, call := f.lastCall()
if binary != "pandoc" {
t.Errorf("expected pandoc binary, got %q", binary)
}
if !contains(call, "--template=/tpl/viewer-template.html") {
t.Errorf("template flag missing: %v", call)
// Template flag must reference an absolute path under the scratch
// dir (no /tpl indirection anymore — the wrapper bind-mounts the
// scratch dir at its own path, so absolute host paths just work).
scratch := f.scratchDir[len(f.scratchDir)-1]
if scratch == "" {
t.Fatalf("ToHTML must pass a scratch dir to the runner")
}
wantTpl := "--template=" + scratch + "/viewer-template.html"
if !contains(call, wantTpl) {
t.Errorf("template flag missing/wrong; want %q in %v", wantTpl, call)
}
if !contains(call, "--toc") {
t.Errorf("TOC flag missing (default NoTOC=false): %v", call)
}
if len(f.mounts) == 0 || len(f.mounts[0]) == 0 {
t.Fatalf("expected at least one bind mount for /tpl")
}
mount := f.mounts[0][0]
if !strings.Contains(mount, ":/tpl:") {
t.Errorf("mount missing /tpl: %q", mount)
}
}
func TestToHTML_NoTOCSuppressesTOC(t *testing.T) {
@ -120,9 +125,9 @@ func TestToHTML_NoTOCSuppressesTOC(t *testing.T) {
}
}
// recordingRunner records every call and returns canned responses
// in sequence. Lets ToPDF tests assert the two-stage pipeline
// (pandoc image then chromium image).
// recordingRunner records every call and returns canned responses in
// sequence. Lets ToPDF tests assert the two-stage pipeline (pandoc
// then chromium).
type recordingRunner struct {
mu sync.Mutex
calls []recordedCall
@ -132,18 +137,18 @@ type recordingRunner struct {
}
type recordedCall struct {
image string
cmd []string
mounts []string
binary string
cmd []string
scratch string
}
func (r *recordingRunner) Run(_ context.Context, image string, _ []byte, mounts []string, cmd []string) ([]byte, error) {
func (r *recordingRunner) Run(_ context.Context, binary string, _ []byte, scratch string, cmd []string) ([]byte, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, recordedCall{
image: image,
cmd: append([]string(nil), cmd...),
mounts: append([]string(nil), mounts...),
binary: binary,
cmd: append([]string(nil), cmd...),
scratch: scratch,
})
if r.cursor >= len(r.resp) {
return nil, nil
@ -169,57 +174,63 @@ func TestScratchDir_UsedByToHTML(t *testing.T) {
if err != nil {
t.Fatalf("ToHTML: %v", err)
}
if len(f.mounts) == 0 || len(f.mounts[0]) == 0 {
t.Fatalf("expected at least one mount")
if len(f.scratchDir) == 0 {
t.Fatalf("expected a scratch dir to be passed to the runner")
}
mount := f.mounts[0][0] // "<host>:/tpl:ro"
if !strings.HasPrefix(mount, scratchRoot+"/") {
t.Errorf("scratch dir not under configured root: %q (root=%q)", mount, scratchRoot)
got := f.scratchDir[0]
if !strings.HasPrefix(got, scratchRoot+"/") {
t.Errorf("scratch dir not under configured root: %q (root=%q)", got, scratchRoot)
}
}
func TestToPDF_TwoStagePipeline(t *testing.T) {
// Stage 1: pandoc emits HTML. Stage 2: chromium reads HTML from
// the bind mount and writes /pdf/out.pdf. The fake runner can't
// the scratch dir and writes out.pdf there. The fake runner can't
// actually write the PDF, so we expect ToPDF to fail at the
// read-back step — but we can still assert the two-stage call
// shape and the right image per stage.
// shape and the right binary per stage.
r := &recordingRunner{
resp: [][]byte{
[]byte("<html><body>fake</body></html>"), // stage 1 stdout
nil, // stage 2 stdout (chromium writes PDF to bind mount)
nil, // stage 2 stdout (chromium writes PDF to scratch)
},
}
InstallRunner(r)
t.Cleanup(func() { InstallRunner(nil) })
SetImages("docker.io/pandoc/latex:latest", "docker.io/zenika/alpine-chrome:latest")
SetBinaries("pandoc", "chromium-browser")
_, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{})
// PDF read-back will fail (fake runner didn't write the file) —
// that's expected for this test which only inspects the call
// shape.
// that's expected for this test which only inspects the call shape.
if err == nil {
t.Fatalf("expected error from PDF read-back; got nil")
}
if len(r.calls) != 2 {
t.Fatalf("expected 2 container calls (pandoc + chromium); got %d", len(r.calls))
t.Fatalf("expected 2 calls (pandoc + chromium); got %d", len(r.calls))
}
if r.calls[0].image != "docker.io/pandoc/latex:latest" {
t.Errorf("stage 1 image: got %q want pandoc/latex", r.calls[0].image)
if r.calls[0].binary != "pandoc" {
t.Errorf("stage 1 binary: got %q want pandoc", r.calls[0].binary)
}
if r.calls[1].image != "docker.io/zenika/alpine-chrome:latest" {
t.Errorf("stage 2 image: got %q want alpine-chrome", r.calls[1].image)
if r.calls[1].binary != "chromium-browser" {
t.Errorf("stage 2 binary: got %q want chromium-browser", r.calls[1].binary)
}
// Stage 2 must include the --print-to-pdf flag pointing at /pdf.
if !contains(r.calls[1].cmd, "--print-to-pdf=/pdf/out.pdf") {
t.Errorf("chromium call missing --print-to-pdf flag: %v", r.calls[1].cmd)
// Stage 2 must include --print-to-pdf pointing at an absolute
// path under the scratch dir.
stage2 := r.calls[1]
if stage2.scratch == "" {
t.Fatalf("chromium call must have a scratch dir")
}
if !contains(r.calls[1].cmd, "--no-sandbox") {
t.Errorf("chromium call missing --no-sandbox: %v", r.calls[1].cmd)
wantPDF := "--print-to-pdf=" + stage2.scratch + "/out.pdf"
if !contains(stage2.cmd, wantPDF) {
t.Errorf("chromium call missing --print-to-pdf=%s/out.pdf: %v", stage2.scratch, stage2.cmd)
}
// Stage 2's bind mount must be writable (chromium writes the PDF).
if len(r.calls[1].mounts) == 0 || !strings.Contains(r.calls[1].mounts[0], ":rw") {
t.Errorf("chromium mount must be :rw, got %v", r.calls[1].mounts)
if !contains(stage2.cmd, "--no-sandbox") {
t.Errorf("chromium call missing --no-sandbox: %v", stage2.cmd)
}
// Stage 2 chromium reads file://<scratch>/in.html.
wantHTML := "file://" + stage2.scratch + "/in.html"
if !contains(stage2.cmd, wantHTML) {
t.Errorf("chromium call missing file:// URL: %v", stage2.cmd)
}
}
@ -255,21 +266,6 @@ func TestMetadataArgs_OmitsEmptyAndOrdersStably(t *testing.T) {
}
}
func TestImageTag(t *testing.T) {
cases := map[string]string{
"docker.io/pandoc/latex:latest": "pandoc/latex",
"docker.io/zenika/alpine-chrome:latest": "zenika/alpine-chrome",
"pandoc/core": "pandoc/core",
"quay.io/example/foo:v1": "example/foo",
"alpine": "alpine",
}
for in, want := range cases {
if got := imageTag(in); got != want {
t.Errorf("imageTag(%q) = %q, want %q", in, got, want)
}
}
}
func TestSingleflight_Collapses(t *testing.T) {
var g singleflightGroup
const N = 50

View file

@ -11,50 +11,45 @@ import (
"time"
)
// remoteURL is set by Probe from cfg.ConvertPodmanSocket. Empty means
// local mode.
var remoteURL atomic.Pointer[string]
// Capabilities is the snapshot of "can we convert right now?". The
// only hard requirement is a container runtime reachable from
// zddc-server — image presence is left to `--pull=missing` at
// conversion time, so a missing image surfaces as a normal
// ConvertError (not a probe failure).
// Capabilities is the snapshot the convert-health endpoint reports
// and the convert entry points consult before exec'ing.
//
// Mode is "local" when the engine creates containers in the same
// process as zddc-server, or "remote" when zddc-server is the client
// of a podman-system-service sidecar (see ContainerRunner doc).
// In the runtime-image model, "Ready" means both binaries
// (pandoc + chromium) are present on PATH. Sandboxing + resource
// limits live in the wrapper scripts that PATH resolves to — out
// of zddc-server's concern. The probe doesn't try to validate
// those; if the wrapper is broken, the first conversion surfaces
// the failure as a ConvertError with the wrapper's stderr.
type Capabilities struct {
Engine string // "podman" | "docker" | ""
EngineVer string // first line of "<engine> --version"
Mode string // "local" or "remote"
RemoteURL string // populated in remote mode
PandocImage string // resolved pandoc image ref
ChromiumImage string // resolved chromium image ref
ProbedAt time.Time
Err error
PandocBinary string // resolved path, e.g. /usr/local/bin/pandoc
PandocVersion string // first line of "pandoc --version"
ChromiumBinary string // resolved path, e.g. /usr/local/bin/chromium-browser
ChromiumVersion string // first line of "chromium-browser --version"
ProbedAt time.Time
Err error
}
// Ready reports whether conversions can be attempted. The first
// conversion may still fail if the configured image isn't reachable
// from the host's registry (the runner will surface a clear error
// from podman/docker stderr).
// Ready reports whether conversions can be attempted.
func (c Capabilities) Ready() bool {
return c.Engine != "" && c.Err == nil
return c.PandocBinary != "" && c.ChromiumBinary != "" && c.Err == nil
}
// Reason returns a short human-friendly explanation when Ready() is
// false. Used as the body of a 503.
func (c Capabilities) Reason() string {
if c.Engine == "" {
return "no container runtime (podman or docker) found on PATH"
}
if c.Err != nil {
if c.Mode == "remote" {
return fmt.Sprintf("podman remote socket unreachable (%s): %s", c.RemoteURL, c.Err.Error())
}
return c.Err.Error()
}
var missing []string
if c.PandocBinary == "" {
missing = append(missing, "pandoc")
}
if c.ChromiumBinary == "" {
missing = append(missing, "chromium-browser")
}
if len(missing) > 0 {
return fmt.Sprintf("conversion binary not found on PATH: %s — runtime image is missing the conversion toolchain (see zddc/runtime.Containerfile)", strings.Join(missing, ", "))
}
return "unavailable"
}
@ -73,143 +68,75 @@ func Available() (Capabilities, bool) {
return *p, p.Ready()
}
// SetRemoteURL installs the podman remote socket URL for subsequent
// Probe / Reprobe calls. Empty means "local mode" (the engine binary
// creates containers in the same process). Called from
// cmd/zddc-server/main.go after flag parsing, before Probe.
func SetRemoteURL(url string) {
s := url
remoteURL.Store(&s)
}
func currentRemoteURL() string {
if p := remoteURL.Load(); p != nil {
return *p
}
return ""
}
// Probe locates the container engine and installs a containerRunner
// as the package default. Call once at server startup. Returns the
// captured Capabilities for logging.
// Probe resolves the conversion binaries on PATH and installs the
// localRunner. Call once at server startup. Returns the captured
// Capabilities for logging.
//
// Engine order: engineOverride (if non-empty) → podman → docker. First
// hit wins. Image presence is NOT probed: the runner uses
// `--pull=missing` so the first conversion request will pull whichever
// image it needs.
// Image responsibility: the binaries on PATH should be the wrapper
// scripts at /usr/local/bin/{pandoc,chromium-browser} (shipped by
// zddc/runtime.Containerfile). Each wrapper handles cgroup setup
// + bwrap sandbox + exec of the real binary at /usr/bin/<name>.
// If an operator runs zddc-server outside the runtime image with
// raw pandoc / chromium on PATH, the conversion still works but
// without the per-call sandbox + resource caps.
//
// In remote mode (SetRemoteURL with non-empty URL), the probe also
// invokes `<engine> --remote --url=<url> version` to confirm the
// sidecar's socket is reachable. A reachable-engine-but-unreachable-
// socket state surfaces as Ready=false so conversion requests serve
// 503 until the sidecar comes up.
//
// Any failure here is non-fatal: the server still starts, conversion
// Failure here is non-fatal: the server still starts, conversion
// endpoints just return 503.
func Probe(ctx context.Context, engineOverride string) Capabilities {
func Probe(ctx context.Context) Capabilities {
probeCool.Lock()
defer probeCool.Unlock()
now := time.Now()
rURL := currentRemoteURL()
c := Capabilities{
PandocImage: currentPandocImage(),
ChromiumImage: currentChromiumImage(),
Mode: "local",
RemoteURL: rURL,
ProbedAt: now,
c := Capabilities{ProbedAt: time.Now()}
pandocBin := currentPandocBinary()
chromiumBin := currentChromiumBinary()
if p, err := exec.LookPath(pandocBin); err == nil {
c.PandocBinary = p
if v, err := probeVersion(ctx, p); err == nil {
c.PandocVersion = v
}
}
if rURL != "" {
c.Mode = "remote"
if p, err := exec.LookPath(chromiumBin); err == nil {
c.ChromiumBinary = p
if v, err := probeVersion(ctx, p); err == nil {
c.ChromiumVersion = v
}
}
engine := resolveEngine(engineOverride)
if engine == "" {
c.Err = fmt.Errorf("no container runtime found (tried: %s)", strings.Join(enginesTried(engineOverride), ", "))
if c.PandocBinary == "" || c.ChromiumBinary == "" {
c.Err = fmt.Errorf("%s", c.Reason())
caps.Store(&c)
slog.Warn("convert: probe failed", "reason", c.Err.Error())
return c
}
c.Engine = engine
if v, err := probeVersion(ctx, engine); err == nil {
c.EngineVer = v
}
if rURL != "" {
if err := probeRemoteSocket(ctx, engine, rURL); err != nil {
c.Err = err
caps.Store(&c)
slog.Warn("convert: remote socket probe failed",
"engine", engine, "remote_url", rURL, "err", err)
return c
}
}
InstallRunner(newContainerRunner(engine, rURL))
InstallRunner(newLocalRunner())
caps.Store(&c)
slog.Info("convert: ready",
"engine", engine,
"engine_version", c.EngineVer,
"mode", c.Mode,
"remote_url", c.RemoteURL,
"pandoc_image", c.PandocImage,
"chromium_image", c.ChromiumImage)
"pandoc_binary", c.PandocBinary,
"pandoc_version", c.PandocVersion,
"chromium_binary", c.ChromiumBinary,
"chromium_version", c.ChromiumVersion)
return c
}
// probeRemoteSocket runs `<engine> --remote --url=<url> version` with
// a short timeout. Returns nil on success; a wrapped error otherwise.
// The remote URL is typically a Unix socket path
// (unix:///var/run/podman/podman.sock) in the sidecar pattern but a
// TCP form (tcp://host:port) is accepted too.
func probeRemoteSocket(ctx context.Context, engine, url string) error {
c := exec.CommandContext(ctx, engine, "--remote", "--url="+url, "version", "--format={{.Client.Version}}")
out, err := c.CombinedOutput()
if err != nil {
return fmt.Errorf("podman --remote version: %w (output: %s)", err, strings.TrimSpace(string(out)))
}
return nil
}
// Reprobe re-runs Probe with the existing configuration. Used by the
// handler when a request hits a not-Ready state — gives the operator
// a way to recover (e.g. installed podman after the server started)
// without a server restart. Cooldown of 60 s between probes to keep
// error-path requests cheap.
func Reprobe(ctx context.Context, engineOverride string) Capabilities {
// Reprobe re-runs Probe with the existing configuration. Used by
// the handler when a request hits a not-Ready state — gives the
// operator a way to recover (e.g. installed pandoc after server
// start) without a server restart. Cooldown of 60 s between probes
// to keep error-path requests cheap.
func Reprobe(ctx context.Context) Capabilities {
if p := caps.Load(); p != nil {
if time.Since(p.ProbedAt) < 60*time.Second {
return *p
}
}
return Probe(ctx, engineOverride)
return Probe(ctx)
}
func resolveEngine(override string) string {
if override != "" {
if p, err := exec.LookPath(override); err == nil {
return p
}
return ""
}
for _, name := range []string{"podman", "docker"} {
if p, err := exec.LookPath(name); err == nil {
return p
}
}
return ""
}
func enginesTried(override string) []string {
if override != "" {
return []string{override}
}
return []string{"podman", "docker"}
}
func probeVersion(ctx context.Context, engine string) (string, error) {
c := exec.CommandContext(ctx, engine, "--version")
func probeVersion(ctx context.Context, binary string) (string, error) {
c := exec.CommandContext(ctx, binary, "--version")
out, err := c.CombinedOutput()
if err != nil {
return "", err

View file

@ -10,39 +10,45 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
// Runner executes a conversion sub-process and returns its stdout.
// The host-side implementation (containerRunner) wraps `podman run`
// or `docker run`; tests use a fake.
// Runner executes a conversion binary and returns its stdout. The
// production implementation (localRunner) just exec's the binary
// directly. Tests use a fake.
//
// image is the OCI image to invoke (e.g. "docker.io/pandoc/latex:latest"
// or "docker.io/zenika/alpine-chrome:latest"). stdin is piped to the
// container's stdin. cmd is the argv passed *to the image's entrypoint*
// — for pandoc/latex the entrypoint is `pandoc`, for alpine-chrome it
// is `chromium-browser`. mounts is a list of "<hostPath>:<containerPath>"
// specs handed to --volume (":ro" is added if no mode segment is
// present).
// binary is the PATH-resolvable name (or absolute path) of the
// conversion tool — typically "pandoc" or "chromium-browser". In the
// production runtime image those names resolve to wrapper scripts at
// /usr/local/bin/ that put the real binary into a cgroup + bwrap
// sandbox before exec'ing it. From zddc-server's perspective, that
// indirection is invisible: it just sees pandoc behavior.
//
// All exec calls in this package go through Runner.Run. This is the
// first os/exec site in the codebase; the hardening here is the
// pattern for future shell-outs.
// stdin is piped to the binary's stdin. scratchDir is an optional
// host directory the binary needs to read from / write to (template
// + intermediate HTML + PDF output); passed to the child via the
// ZDDC_SCRATCH env var, which the wrapper script bind-mounts into
// the sandbox at the same path. Empty means "no scratch dir
// needed" (DOCX flow — stdin to stdout, no files).
//
// cmd is the argv passed to the binary. Same shape across all
// runners; no shell quoting; no engine-specific flags.
//
// All exec calls in this package go through Runner.Run.
type Runner interface {
Run(ctx context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error)
Run(ctx context.Context, binary string, stdin []byte, scratchDir string, cmd []string) ([]byte, error)
}
// ErrUnavailable means no container runtime is present on the host.
// Handlers translate to HTTP 503.
// ErrUnavailable means the conversion binary couldn't be found on
// PATH. Handlers translate to HTTP 503.
var ErrUnavailable = errors.New("conversion unavailable")
// ConvertError carries the failure surface from a non-zero exit.
// Stderr is captured (truncated to 4 KiB by the runner) so callers can
// surface pandoc/chromium's own complaint.
// Stderr is captured (truncated to 4 KiB by the runner) so callers
// can surface the binary's own complaint.
type ConvertError struct {
Tool string // image name fragment, used only for logging
Tool string // binary name, used only for logging
ExitCode int
Stderr string
Cause error
@ -53,78 +59,154 @@ func (e *ConvertError) Error() string {
return "<nil>"
}
if e.Stderr != "" {
return fmt.Sprintf("%s exit %d: %s", e.Tool, e.ExitCode, strings.TrimSpace(e.Stderr))
return fmt.Sprintf("%s exit %d: %s", e.Tool, e.ExitCode, e.Stderr)
}
return fmt.Sprintf("%s exit %d: %v", e.Tool, e.ExitCode, e.Cause)
}
func (e *ConvertError) Unwrap() error { return e.Cause }
// containerRunner runs each conversion inside a fresh container.
// The engine ("podman" preferred, "docker" fallback) is resolved once
// at startup by Probe. Resource limits are configurable via
// SetLimits (called from main.go after flag parsing). Images are passed
// per call so the same runner handles both pandoc and chromium
// invocations.
// localRunner exec's the conversion binary directly. The runtime
// image's wrapper script (at /usr/local/bin/<binary>) handles
// sandboxing + resource limits BETWEEN this exec and the real
// binary — invisible to this Runner.
//
// Two modes:
//
// - **local** (remoteURL=""): the engine binary creates containers
// directly on the host that runs zddc-server. Used for bare-metal
// and host-podman deployments. Requires podman or docker on PATH.
//
// - **remote** (remoteURL="unix:///var/run/podman/podman.sock" or
// similar): the engine binary is the local podman CLIENT, invoked
// as `podman --remote --url=<remoteURL> run …`; the actual
// container creation happens in whatever process owns the socket
// (typically a `podman system service` sidecar in the same pod).
// Used for the Kubernetes sidecar pattern so zddc-server's own
// pod stays unprivileged. Bind-mount paths must resolve identically
// on both sides — see scratchDir.
//
// The runner relies on `--pull=missing` so the operator never has to
// pre-pull images: the first request that needs an image pulls it,
// subsequent requests use the local cache. Both podman and docker
// honour this flag identically.
type containerRunner struct {
mu sync.RWMutex
engine string
remoteURL string
memMiB int
cpus string
pids int
timeout time.Duration
// Resource limits stored here are advisory only; the wrapper reads
// them via env (ZDDC_CONV_MEM_MAX, ZDDC_CONV_PIDS_MAX) and applies
// them to its transient cgroup. Wall-clock timeout IS enforced
// here via context.WithTimeout.
type localRunner struct {
mu sync.RWMutex
memMiB int
pids int
timeout time.Duration
}
func newLocalRunner() *localRunner {
return &localRunner{
memMiB: 1024, // 1 GiB — matches the wrapper's default
pids: 256,
timeout: 60 * time.Second,
}
}
// SetLimits updates the resource ceilings advertised to the wrapper
// script via env vars + the wall-clock timeout enforced here.
// Zero values keep the previous setting (or constructor defaults).
// Safe to call from multiple goroutines.
func (lr *localRunner) SetLimits(memMiB int, pids int, timeout time.Duration) {
lr.mu.Lock()
defer lr.mu.Unlock()
if memMiB > 0 {
lr.memMiB = memMiB
}
if pids > 0 {
lr.pids = pids
}
if timeout > 0 {
lr.timeout = timeout
}
}
func (lr *localRunner) Run(ctx context.Context, binary string, stdin []byte, scratchDir string, cmd []string) ([]byte, error) {
lr.mu.RLock()
memMiB := lr.memMiB
pids := lr.pids
timeout := lr.timeout
lr.mu.RUnlock()
if binary == "" {
return nil, ErrUnavailable
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
c := exec.CommandContext(runCtx, binary, cmd...)
c.Cancel = func() error {
if c.Process == nil {
return nil
}
return c.Process.Kill()
}
c.WaitDelay = 2 * time.Second
c.SysProcAttr = sysProcAttr()
// Minimal env passed to the wrapper. The wrapper does
// --clearenv inside the bwrap sandbox so the real binary
// sees only what bwrap re-injects (HOME, PATH, LANG). These
// vars are read by the WRAPPER itself, not the binary, to
// drive its cgroup setup + scratch-dir bind mount.
env := []string{
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.TempDir(),
fmt.Sprintf("ZDDC_CONV_MEM_MAX=%dM", memMiB),
fmt.Sprintf("ZDDC_CONV_PIDS_MAX=%d", pids),
}
if scratchDir != "" {
env = append(env, "ZDDC_SCRATCH="+scratchDir)
}
c.Env = env
c.Stdin = bytes.NewReader(stdin)
var stdoutBuf bytes.Buffer
c.Stdout = &limitWriter{w: &stdoutBuf, max: 128 << 20}
stderr := newRingWriter(4 << 10)
c.Stderr = stderr
if err := c.Run(); err != nil {
exitCode := -1
if ee, ok := err.(*exec.ExitError); ok {
exitCode = ee.ExitCode()
}
if runCtx.Err() == context.DeadlineExceeded {
return nil, &ConvertError{
Tool: binary,
ExitCode: exitCode,
Stderr: stderr.String(),
Cause: fmt.Errorf("timeout after %s: %w", timeout, runCtx.Err()),
}
}
return nil, &ConvertError{
Tool: binary,
ExitCode: exitCode,
Stderr: stderr.String(),
Cause: err,
}
}
return stdoutBuf.Bytes(), nil
}
var (
// shared default runner, populated by InstallRunner (called from
// the health probe at startup once the engine is known).
// the health probe at startup once the binaries are confirmed).
defaultRunnerMu sync.RWMutex
defaultRunner Runner
)
// InstallRunner sets the package-level Runner used by ToDocx/ToHTML/ToPDF.
// Tests inject a fake; production code lets the health probe install a
// containerRunner. Safe to call from multiple goroutines.
// InstallRunner sets the package-level Runner used by ToDocx/ToHTML/
// ToPDF. Tests inject a fake; production code lets the health probe
// install a localRunner. Safe to call from multiple goroutines.
func InstallRunner(r Runner) {
defaultRunnerMu.Lock()
defaultRunner = r
defaultRunnerMu.Unlock()
}
// ConfigureLimits applies resource limits to the package-level Runner,
// if it's a containerRunner. No-op when no runner is installed yet
// (the probe failed) or when the installed runner doesn't accept
// ConfigureLimits applies resource limits to the package-level
// Runner, if it's a localRunner. No-op when no runner is installed
// yet (the probe failed) or when the installed runner doesn't accept
// limits (e.g. a test fake). Zero values keep the previous setting.
//
// Called from cmd/zddc-server/main.go after Probe so the limits from
// the operator's flags take effect before any conversion request lands.
func ConfigureLimits(memMiB int, cpus string, pids int, timeout time.Duration) {
// Called from cmd/zddc-server/main.go after Probe so the limits
// from the operator's flags take effect before any conversion
// request lands.
func ConfigureLimits(memMiB int, pids int, timeout time.Duration) {
defaultRunnerMu.RLock()
r := defaultRunner
defaultRunnerMu.RUnlock()
if cr, ok := r.(*containerRunner); ok {
cr.SetLimits(memMiB, cpus, pids, timeout)
if lr, ok := r.(*localRunner); ok {
lr.SetLimits(memMiB, pids, timeout)
}
}
@ -135,204 +217,8 @@ func currentRunner() Runner {
return r
}
// SetLimits updates the resource ceilings used for subsequent Run
// invocations. Zero values keep the previous setting (or the defaults
// set at construction). Safe to call from multiple goroutines.
func (cr *containerRunner) SetLimits(memMiB int, cpus string, pids int, timeout time.Duration) {
cr.mu.Lock()
defer cr.mu.Unlock()
if memMiB > 0 {
cr.memMiB = memMiB
}
if cpus != "" {
cr.cpus = cpus
}
if pids > 0 {
cr.pids = pids
}
if timeout > 0 {
cr.timeout = timeout
}
}
func newContainerRunner(engine, remoteURL string) *containerRunner {
return &containerRunner{
engine: engine,
remoteURL: remoteURL,
memMiB: 512,
cpus: "2",
pids: 100,
timeout: 30 * time.Second,
}
}
// Run executes one container invocation. cmd is the argv passed to the
// image's entrypoint (pandoc for pandoc/latex, chromium-browser for
// alpine-chrome). mounts is a list of "<hostPath>:<containerPath>"
// strings; ":ro" is appended when no mode segment is present. stdin is
// piped to the container, stdout is returned as bytes (capped at
// 128 MiB).
//
// Hardening:
// - --pull=missing: image is fetched on first use, cached after.
// Operator only needs podman/docker installed; no manual pull.
// - --rm: container is removed on exit, even if killed.
// - --network=none: no network inside the container. Prevents data
// exfiltration through embedded URLs in source documents.
// - --read-only + tmpfs on /tmp and /run: image fs is immutable;
// pandoc/chromium scratch goes to tmpfs only.
// - --memory / --cpus / --pids-limit: kernel-enforced caps.
// - --cap-drop=ALL + --security-opt=no-new-privileges: standard
// container-escape hardening.
// - context-cancel kill + WaitDelay: a wedged podman gets force-
// killed; pipes drop after 2s so we don't leak goroutines.
// - cmd.Env minimal: only PATH + HOME are passed through to the
// engine binary; the container itself sees only what the image
// bakes in plus what --env adds (HOME=/tmp).
//
// Note: --user is intentionally NOT set so each image uses its
// default user (pandoc/latex runs as root, alpine-chrome runs as
// uid 1000). With --read-only + tmpfs + --cap-drop=ALL +
// --network=none + --no-new-privileges the additional defense from
// forcing nobody is small and would break alpine-chrome's own
// user-data-dir layout.
func (cr *containerRunner) Run(ctx context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error) {
cr.mu.RLock()
engine := cr.engine
remoteURL := cr.remoteURL
memMiB := cr.memMiB
cpus := cr.cpus
pids := cr.pids
timeout := cr.timeout
cr.mu.RUnlock()
if engine == "" {
return nil, ErrUnavailable
}
if image == "" {
return nil, fmt.Errorf("convert.Run: image is empty")
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Client args. In remote mode, prepend --remote and --url so the
// podman CLI dispatches the request to the sidecar's
// `podman system service` instead of creating a container locally.
// The remaining flags (--rm, --pull=missing, etc.) apply to the
// container that the remote daemon will create — same wire format
// as local mode.
var args []string
if remoteURL != "" {
args = append(args, "--remote", "--url="+remoteURL)
}
args = append(args,
"run",
"--rm",
"--pull=missing",
"-i",
)
// --userns=host only in local mode: needed when zddc-server itself
// is the one running podman inside a Kubernetes pod, because the
// kernel won't let an inner rootless podman set up its own userns
// via newuidmap. In remote (sidecar) mode the sidecar runs as root
// and creates the inner container in its own (rootful) namespace,
// so --userns=host is unnecessary and potentially noisy.
if remoteURL == "" {
args = append(args, "--userns=host")
}
args = append(args,
"--network=none",
"--read-only",
// /tmp must be large enough to host chromium's shared-memory
// fallback (--disable-dev-shm-usage redirects /dev/shm writes
// here) plus the user-data-dir. 256 MiB is plenty for the
// HTML→PDF flow; pandoc itself uses almost none.
"--tmpfs=/tmp:size=256m,exec",
"--tmpfs=/run:size=4m",
fmt.Sprintf("--memory=%dm", memMiB),
fmt.Sprintf("--cpus=%s", cpus),
fmt.Sprintf("--pids-limit=%d", pids),
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--env=HOME=/tmp",
"--workdir=/tmp",
)
for _, m := range mounts {
if !strings.Contains(m, ":ro") && !strings.Contains(m, ":rw") {
m += ":ro"
}
args = append(args, "--volume="+m)
}
args = append(args, image)
args = append(args, cmd...)
c := exec.CommandContext(runCtx, engine, args...)
c.Cancel = func() error {
if c.Process == nil {
return nil
}
return c.Process.Kill()
}
c.WaitDelay = 2 * time.Second
c.SysProcAttr = sysProcAttr()
c.Env = []string{
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.TempDir(),
}
c.Stdin = bytes.NewReader(stdin)
var stdoutBuf bytes.Buffer
c.Stdout = &limitWriter{w: &stdoutBuf, max: 128 << 20}
stderr := newRingWriter(4 << 10)
c.Stderr = stderr
err := c.Run()
if err != nil {
exitCode := -1
if ee, ok := err.(*exec.ExitError); ok {
exitCode = ee.ExitCode()
}
toolName := imageTag(image)
if runCtx.Err() == context.DeadlineExceeded {
return nil, &ConvertError{
Tool: toolName,
ExitCode: exitCode,
Stderr: stderr.String(),
Cause: fmt.Errorf("timeout after %s: %w", timeout, runCtx.Err()),
}
}
return nil, &ConvertError{
Tool: toolName,
ExitCode: exitCode,
Stderr: stderr.String(),
Cause: err,
}
}
return stdoutBuf.Bytes(), nil
}
// imageTag extracts a short name for an image reference, used as the
// "Tool" label on ConvertError. "docker.io/pandoc/latex:latest" →
// "pandoc/latex".
func imageTag(image string) string {
s := image
// Strip registry prefix.
if i := strings.Index(s, "/"); i >= 0 {
if strings.Contains(s[:i], ".") || strings.Contains(s[:i], ":") {
s = s[i+1:]
}
}
// Strip tag suffix.
if i := strings.LastIndex(s, ":"); i >= 0 {
s = s[:i]
}
return s
}
// limitWriter caps the underlying buffer at max bytes. Writes past the
// cap return io.ErrShortWrite, which surfaces as a Run() error — the
// limitWriter caps the underlying buffer at max bytes. Writes past
// the cap return an error which surfaces as a Run() error — the
// caller then maps to 422 (output too large) at the handler edge.
type limitWriter struct {
w io.Writer
@ -355,9 +241,9 @@ func (l *limitWriter) Write(p []byte) (int, error) {
return n, err
}
// ringWriter keeps only the tail of what's written — useful for stderr
// capture where the most-recent bytes are the ones with the actual
// error message and earlier output is usually progress noise.
// ringWriter keeps only the tail of what's written — useful for
// stderr capture where the most-recent bytes carry the actual error
// message and earlier output is usually progress noise.
type ringWriter struct {
mu sync.Mutex
buf []byte
@ -391,16 +277,14 @@ func (r *ringWriter) String() string {
// writeAssetsToScratch materialises the embedded viewer-template.html
// and custom.css into a fresh scratch dir and returns the host path.
// Caller is responsible for os.RemoveAll(dir) when done. Used by
// ToHTML which needs the template visible inside the container.
// ToHTML which needs the template visible inside the sandbox.
//
// scratchRoot controls where the temp dir lands. Empty means "use
// $TMPDIR" (local mode default). In remote/sidecar mode the caller
// passes the shared mount path (e.g. "/work") so the podman-service
// sidecar sees the bind-mount source at the same path.
// scratchRoot controls where the temp dir lands. Empty means
// "use $TMPDIR".
//
// Files are written world-readable so the container's default user
// (root for pandoc/latex, uid 1000 for alpine-chrome) can read them
// through the read-only bind mount regardless of the host's umask.
// Files are written world-readable so the binary's default user can
// read them through the wrapper's bind mount regardless of the
// host's umask.
func writeAssetsToScratch(scratchRoot string) (string, error) {
dir, err := os.MkdirTemp(scratchRoot, "zddc-convert-")
if err != nil {

View file

@ -38,7 +38,7 @@ func safeJoin(fsRoot, relPath string) (string, bool) {
// The decider is queried per subdirectory; nil falls back to the internal
// Go evaluator (policy.InternalDecider) for tests that don't wire up
// an explicit decider.
func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string, includeHidden bool) ([]listing.FileInfo, error) {
func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string, includeHidden, elevated bool) ([]listing.FileInfo, error) {
if decider == nil {
decider = &policy.InternalDecider{}
}
@ -47,6 +47,17 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
return nil, os.ErrNotExist
}
// Virtual received/ window: when the URL points at <workflow>/received/
// (i.e. the URL traverses a `received` segment whose workflow-folder
// parent declares received_path in its .zddc), redirect the listing
// source to the canonical received/<tracking>/ path. Entry URLs stay
// rooted at baseURL so the browse client keeps the workflow context —
// drag-drop onto an entry here PUTs to <workflow>/received/<file>,
// which serveFilePut intercepts and rewrites to <workflow>/<base>+C<n><suffix>.
if vr := zddc.ResolveVirtualReceived(fsRoot, strings.TrimSuffix(baseURL, "/")); vr.Resolved && vr.IsRoot {
absDir = vr.ReceivedAbs
}
entries, err := os.ReadDir(absDir)
if err != nil {
// Empty-listing fallback for cascade-declared paths. A fresh
@ -82,6 +93,16 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
declaredSet[strings.ToLower(name)] = true
}
// Parent-dir chain + active-admin status. Files in this directory
// inherit authorization from this chain, so we compute it once
// and reuse for every file entry's Writable bit. Subdirectories
// build their own chain (the child cascade can differ — e.g. a
// per-user fenced home).
parentChain, _ := zddc.EffectivePolicy(fsRoot, absDir)
principal := zddc.Principal{Email: userEmail, Elevated: elevated}
parentActiveAdmin := elevated && userEmail != "" &&
zddc.IsAdminForChain(parentChain, userEmail)
for _, entry := range entries {
name := entry.Name()
@ -112,10 +133,19 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
continue
}
subURLPath := baseURL + name + "/"
allowed, _ := policy.AllowFromChain(ctx, decider, chain, userEmail, subURLPath)
allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, subURLPath)
if !allowed {
continue // omit denied directories silently
}
// Pull the title from this subdir's own .zddc, if it has
// one. Lets clients render project / folder names without
// a second round-trip per entry — the landing page used
// to need a bespoke /api with this info; now the generic
// listing carries it.
var title string
if zf, perr := zddc.ParseFile(filepath.Join(subAbs, ".zddc")); perr == nil {
title = zf.Title
}
fi := listing.FileInfo{
Name: name + "/",
Size: info.Size(),
@ -125,6 +155,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
IsDir: true,
DisplayName: displayName,
Declared: declared,
Title: title,
}
result = append(result, fi)
continue
@ -141,6 +172,26 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
DisplayName: displayName,
Declared: declared,
}
// Writable surfaces whether THIS principal could PUT this file
// — same decision as the file API's authorizeAction would
// reach. Uses the parent-dir chain (computed once above);
// active-admin status short-circuits the per-file decider
// query when the principal already holds admin authority.
// .zddc requires ActionAdmin (not ActionWrite) so the verb
// matches the file API's gate at fileapi.go:362-364.
action := policy.ActionWrite
if name == ".zddc" {
action = policy.ActionAdmin
}
fileURL := baseURL + name
if parentActiveAdmin {
fi.Writable = true
} else {
allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, action)
if allowed {
fi.Writable = true
}
}
result = append(result, fi)
}
@ -161,9 +212,143 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
// to real ones.
result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...)
// Project-level virtual table views: SSR aggregates one row per
// party folder under archive/; MDL/RSK rollups aggregate every
// row from each party's mdl/ or rsk/. The listing surfaces
// synthetic row entries (Writable bit per the canonical
// archive/<party>/ chain) plus synthetic table.yaml/form.yaml
// entries so the tables tool's client-side walkServer finds the
// spec without a 404 round-trip. Spec bytes are served by the
// main.go IsDefaultSpec fallback; row reads go through
// handler.ServeVirtualViewRow which path-injects name/party.
if vv := zddc.ResolveVirtualView(fsRoot, strings.TrimSuffix(baseURL, "/")); vv.Resolved && vv.Kind.IsRootKind() {
partyChains := make(map[string]zddc.PolicyChain)
chainFor := func(partyAbs string) zddc.PolicyChain {
if c, ok := partyChains[partyAbs]; ok {
return c
}
c, _ := zddc.EffectivePolicy(fsRoot, partyAbs)
partyChains[partyAbs] = c
return c
}
appendVirtualRow := func(syntheticName, partyAbs string) {
rowURL := baseURL + url.PathEscape(syntheticName)
chain := chainFor(partyAbs)
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, rowURL); !allowed {
return
}
partyActiveAdmin := elevated && userEmail != "" &&
zddc.IsAdminForChain(chain, userEmail)
writable := partyActiveAdmin
if !writable {
allowed, _ := policy.AllowActionFromChainP(ctx, decider, chain, principal, rowURL, policy.ActionWrite)
writable = allowed
}
result = append(result, listing.FileInfo{
Name: syntheticName,
URL: rowURL,
IsDir: false,
Virtual: true,
Writable: writable,
})
}
switch vv.Slot {
case "ssr":
parties, _ := zddc.ListSSRParties(fsRoot, vv.ProjectAbs)
for _, party := range parties {
partyAbs := filepath.Join(vv.ProjectAbs, "archive", party)
appendVirtualRow(party+".yaml", partyAbs)
}
case "mdl", "rsk":
rows, _ := zddc.ListRollupRows(fsRoot, vv.ProjectAbs, vv.Slot)
for _, row := range rows {
partyAbs := filepath.Join(vv.ProjectAbs, "archive", row.Party)
appendVirtualRow(row.SyntheticName, partyAbs)
}
}
result = append(result,
listing.FileInfo{Name: "table.yaml", URL: baseURL + "table.yaml", IsDir: false, Virtual: true},
listing.FileInfo{Name: "form.yaml", URL: baseURL + "form.yaml", IsDir: false, Virtual: true},
)
}
// Workflow folder: append a virtual `received/` entry whose backing
// is .zddc.received_path. The entry's URL stays under the workflow
// folder (baseURL + "received/") so a click navigates "into" the
// synthetic child — the listing handler then swaps the read source
// to the canonical received/<tracking>/ path while keeping the URL
// context intact. Suppressed if a real `received/` already exists on
// disk (operator override).
if rp := zddc.WorkflowReceivedPath(absDir); rp != "" {
hasReal := false
for _, fi := range result {
if fi.IsDir && strings.EqualFold(strings.TrimSuffix(fi.Name, "/"), "received") {
hasReal = true
break
}
}
if !hasReal {
result = append(result, listing.FileInfo{
Name: "received/",
URL: baseURL + "received/",
IsDir: true,
Virtual: true,
})
}
}
// Surface a virtual `.zddc` entry when the on-disk file doesn't
// exist. The /<dir>/.zddc URL always serves SOMETHING — real
// bytes if present, a synthetic placeholder body otherwise (see
// handler.ServeZddcFile) — so the entry resolves to a real
// editable view either way. PUT-ing back materialises the file
// on disk and the listing converts to a real (non-virtual) row
// automatically on the next fetch. Only emitted when the caller
// asked for hidden entries (?hidden=1), matching the dot-prefix
// hide rule used for every other dotfile.
if includeHidden {
if v, ok := virtualZddcEntry(ctx, decider, parentChain, principal, parentActiveAdmin, absDir, baseURL); ok {
result = append(result, v)
}
}
return result, nil
}
// virtualZddcEntry returns a synthetic listing entry for absDir/.zddc
// when no real file exists. The cascade has effective rules at every
// path (down through embedded defaults), so editing this virtual
// entry is always meaningful — a save promotes it to a real on-disk
// .zddc that overrides ancestor levels for this directory.
//
// Writable mirrors the real-file path: ActionAdmin against the parent
// chain, short-circuited when the principal already holds admin
// authority. An elevated admin sees writable=true and the editor lets
// them save; a non-admin sees writable=false and the editor mounts
// read-only.
func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain zddc.PolicyChain, principal zddc.Principal, parentActiveAdmin bool, absDir, baseURL string) (listing.FileInfo, bool) {
zddcPath := filepath.Join(absDir, ".zddc")
if _, err := os.Stat(zddcPath); err == nil {
return listing.FileInfo{}, false
} else if !os.IsNotExist(err) {
return listing.FileInfo{}, false
}
writable := parentActiveAdmin
if !writable {
allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, baseURL+".zddc", policy.ActionAdmin)
writable = allowed
}
return listing.FileInfo{
Name: ".zddc",
URL: baseURL + ".zddc",
IsDir: false,
Virtual: true,
Writable: writable,
}, true
}
// virtualCanonicalFolders returns synthetic entries for any
// cascade-declared child name that's absent from the on-disk
// listing. Sources from zddc.ChildrenDeclaredAt — the cascade's

View file

@ -28,7 +28,7 @@ func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
}
zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false)
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
@ -56,7 +56,7 @@ func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
}
zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false)
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
@ -74,7 +74,7 @@ func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
}
zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/", false)
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
@ -92,7 +92,7 @@ func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
}
zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/", false)
got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
@ -113,7 +113,7 @@ func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
got, err := ListDirectory(context.Background(), nil, root,
"Proj/working/alice@example.com", "alice@example.com",
"/Proj/working/alice@example.com/", false)
"/Proj/working/alice@example.com/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
@ -132,7 +132,7 @@ func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
}
zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/", false)
got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
@ -165,7 +165,7 @@ func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
for _, stage := range []string{"working", "staging", "reviewing", "archive"} {
got, err := ListDirectory(context.Background(), nil, root,
"Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false)
"Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false, false)
if err != nil {
t.Errorf("ListDirectory(Proj/%s) on missing dir: err = %v, want nil", stage, err)
continue
@ -199,7 +199,7 @@ func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
_, err := ListDirectory(context.Background(), nil, root,
"Proj/random-folder-that-doesnt-exist", "alice@example.com",
"/Proj/random-folder-that-doesnt-exist/", false)
"/Proj/random-folder-that-doesnt-exist/", false, false)
if !os.IsNotExist(err) {
t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err)
}

View file

@ -0,0 +1,274 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"gopkg.in/yaml.v3"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// Accept Transmittal — the doc-controller's "file a counterparty
// upload into the immutable received archive" step. Right-click on a
// single transmittal folder under archive/<party>/incoming/ in the
// browse app; the client POSTs X-ZDDC-Op: accept-transmittal with the
// body below.
//
// Authorisation model — same primitives as Plan Review, no exceptions:
//
// - ActionWrite on incoming/<transmittal>/ (move source).
// document_controller has rwcd on incoming/ via the cascade defaults.
// - ActionCreate on received/<tracking>/ (move destination, WORM zone).
// document_controller has `cr` here via worm: [document_controller].
//
// Operation:
//
// 1. Parse URL — must be a direct child of archive/<party>/incoming/.
// 2. Validate the transmittal folder name via ParseTransmittalFolder
// (date, tracking, status, title). Reject if not well-formed.
// 3. Validate every file in the folder via ParseFilename. Each file's
// parsed tracking must match the folder's tracking. Reject on any
// non-conformance — client should cancel and tell sender to fix.
// 4. ACL pre-flight (source write, destination create).
// 5. mkdir received/ (parent of the destination) if missing.
// 6. If received/<tracking>/ does NOT exist → os.Rename the whole
// folder (atomic, fast).
// If received/<tracking>/ DOES exist (re-submission of the same
// tracking) → per-file move. Refuse if any child filename already
// exists at the destination — WORM forbids overwrite.
// 7. Optional Plan Review chain: when the body's setup_plan_review
// flag is true, the same handler dispatches through Plan Review's
// three-stage flow against the new received/<tracking>/ URL. The
// ACL gates re-run there (idempotent against the same principal),
// which is correct: both authorities are required by design.
//
// The accept itself does NOT write received/<tracking>/.zddc — the
// cascade's worm: [document_controller] inheritance is enough. If
// Plan Review is chained, IT writes the .zddc with planned dates.
// Filesystem mtime on the moved folder records when the accept
// happened; the audit log records who.
const opAcceptTransmittal = "accept-transmittal"
// incomingURLPattern matches /<project>/archive/<party>/incoming/<transmittal>/.
var incomingURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/incoming/([^/]+)/?$`)
type acceptRequest struct {
ReceivedDate string `yaml:"received_date"`
SetupPlanReview bool `yaml:"setup_plan_review"`
ReviewLead string `yaml:"review_lead"`
Approver string `yaml:"approver"`
PlanReviewCompleteDate string `yaml:"plan_review_complete_date"`
PlanResponseDate string `yaml:"plan_response_date"`
}
type acceptResponse struct {
Tracking string `json:"tracking"`
IncomingPath string `json:"incoming_path"`
ReceivedPath string `json:"received_path"`
MovedFiles int `json:"moved_files"`
Merged bool `json:"merged"`
PlanReview *planReviewResponse `json:"plan_review,omitempty"`
}
func serveAcceptTransmittal(cfg config.Config, w http.ResponseWriter, r *http.Request) {
cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/"
m := incomingURLPattern.FindStringSubmatch(cleanURL)
if m == nil {
http.Error(w, "Bad Request — accept-transmittal must POST to /<project>/archive/<party>/incoming/<transmittal>/", http.StatusBadRequest)
return
}
project, party, transmittalFolder := m[1], m[2], m[3]
date, tracking, _, _, ok := zddc.ParseTransmittalFolder(transmittalFolder)
if !ok {
http.Error(w, "Bad Request — folder name does not conform to ZDDC transmittal grammar (expected YYYY-MM-DD_<tracking> (<status>) - <title>)", http.StatusBadRequest)
return
}
_ = date // available for audit; mtime carries the actual accept time
body, ok2 := readBodyCapped(cfg, w, r)
if !ok2 {
return
}
var req acceptRequest
if len(body) > 0 {
if err := yaml.Unmarshal(body, &req); err != nil {
http.Error(w, "Bad Request — could not parse YAML body: "+err.Error(), http.StatusBadRequest)
return
}
}
if req.SetupPlanReview {
if req.ReviewLead == "" || req.Approver == "" ||
req.PlanReviewCompleteDate == "" || req.PlanResponseDate == "" {
http.Error(w, "Bad Request — setup_plan_review requires review_lead, approver, plan_review_complete_date, plan_response_date", http.StatusBadRequest)
return
}
}
incomingAbs := filepath.Join(cfg.Root, project, "archive", party, "incoming", transmittalFolder)
receivedAbs := filepath.Join(cfg.Root, project, "archive", party, "received", tracking)
receivedURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"
// Source must exist as a directory.
srcInfo, err := os.Stat(incomingAbs)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
http.Error(w, "Not Found", http.StatusNotFound)
} else {
http.Error(w, "Internal Server Error — stat source: "+err.Error(), http.StatusInternalServerError)
}
return
}
if !srcInfo.IsDir() {
http.Error(w, "Bad Request — accept-transmittal target is not a directory", http.StatusBadRequest)
return
}
// Validate every file in the folder before any side-effect.
entries, err := os.ReadDir(incomingAbs)
if err != nil {
http.Error(w, "Internal Server Error — read source: "+err.Error(), http.StatusInternalServerError)
return
}
var fileNames []string
var violations []string
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue // skip dotfiles silently (e.g. .zddc dropped by counterparty)
}
if e.IsDir() {
violations = append(violations, name+": nested directories are not permitted in a transmittal folder")
continue
}
parsed := zddc.ParseFilename(name)
if !parsed.Valid {
violations = append(violations, name+": does not conform to ZDDC filename grammar")
continue
}
if parsed.TrackingNumber != tracking {
violations = append(violations, fmt.Sprintf("%s: tracking %q does not match folder tracking %q", name, parsed.TrackingNumber, tracking))
continue
}
fileNames = append(fileNames, name)
}
if len(violations) > 0 {
http.Error(w, "Conflict — transmittal folder contents do not conform:\n"+strings.Join(violations, "\n"), http.StatusConflict)
return
}
if len(fileNames) == 0 {
http.Error(w, "Conflict — transmittal folder is empty", http.StatusConflict)
return
}
// ACL pre-flight: source needs Write (rename out), destination needs Create.
if !authorizeAction(cfg, w, r, incomingAbs, cleanURL, policy.ActionWrite) {
return
}
if !authorizeAction(cfg, w, r, receivedAbs, receivedURL, policy.ActionCreate) {
return
}
email := EmailFromContext(r)
if email == "" {
http.Error(w, "Forbidden — no authenticated principal", http.StatusForbidden)
return
}
// Ensure received/'s parent exists (received/ itself materialises via
// the rename or the per-file moves below).
receivedParent := filepath.Dir(receivedAbs)
if err := os.MkdirAll(receivedParent, 0o755); err != nil {
auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error — mkdir received/: "+err.Error(), http.StatusInternalServerError)
return
}
merged := false
if _, err := os.Stat(receivedAbs); err == nil {
// Re-submission of an already-accepted tracking → merge per-file.
// Refuse any filename collision; WORM forbids overwriting.
merged = true
for _, name := range fileNames {
dst := filepath.Join(receivedAbs, name)
if _, statErr := os.Stat(dst); statErr == nil {
http.Error(w, "Conflict — "+name+" already exists in received/"+tracking+"/ (WORM forbids overwrite)", http.StatusConflict)
return
} else if !errors.Is(statErr, os.ErrNotExist) {
http.Error(w, "Internal Server Error — stat destination: "+statErr.Error(), http.StatusInternalServerError)
return
}
}
for _, name := range fileNames {
src := filepath.Join(incomingAbs, name)
dst := filepath.Join(receivedAbs, name)
if err := os.Rename(src, dst); err != nil {
auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error — rename "+name+": "+err.Error(), http.StatusInternalServerError)
return
}
}
// Best-effort: remove the now-empty incoming folder. Leaves it in
// place if non-empty (e.g. operator left ad-hoc notes alongside
// the conformant files); audit log captures the success either way.
_ = os.Remove(incomingAbs)
} else if errors.Is(err, os.ErrNotExist) {
// Fresh acceptance → atomic folder rename.
if err := os.Rename(incomingAbs, receivedAbs); err != nil {
auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error — rename folder: "+err.Error(), http.StatusInternalServerError)
return
}
} else {
http.Error(w, "Internal Server Error — stat received: "+err.Error(), http.StatusInternalServerError)
return
}
resp := acceptResponse{
Tracking: tracking,
IncomingPath: cleanURL,
ReceivedPath: receivedURL,
MovedFiles: len(fileNames),
Merged: merged,
}
// Optional Plan Review chain. Invokes executePlanReview directly
// against the freshly-created received/<tracking>/ path. The ACL
// gates re-run there — the invoker still needs ActionAdmin on the
// workflow roots and `c` on received/<tracking>/, both of which
// they had a moment ago for the move itself. A chained failure does
// NOT roll back the move: the canonical record is sealed, and the
// user can re-trigger Plan Review later from the received/<tracking>/
// folder context menu.
if req.SetupPlanReview {
planReq := planReviewRequest{
ReviewLead: req.ReviewLead,
Approver: req.Approver,
PlanReviewCompleteDate: req.PlanReviewCompleteDate,
PlanResponseDate: req.PlanResponseDate,
}
prResp, status, msg := executePlanReview(cfg, r, project, party, tracking, planReq)
if status != http.StatusOK {
auditFile(r, "accept-transmittal", cleanURL, status, 0, errors.New(msg))
http.Error(w, "Chained plan-review: "+msg, status)
return
}
resp.PlanReview = prResp
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "fileapi:accept-transmittal")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
auditFile(r, "accept-transmittal", cleanURL+" -> "+receivedURL, http.StatusOK, 0, nil)
}

View file

@ -0,0 +1,193 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// acceptSetup writes a tree with a conforming transmittal folder under
// archive/Acme/incoming/ and an admin grant for alice. Returns the cfg,
// a do() helper, and the root path.
func acceptSetup(t *testing.T) (config.Config, func(target, email string, body []byte) *httptest.ResponseRecorder, string) {
t.Helper()
root := t.TempDir()
mustWriteHelper(t, filepath.Join(root, ".zddc"),
"admins:\n - alice@example.com\n"+
"roles:\n document_controller:\n members: [alice@example.com]\n")
for _, d := range []string{"Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation"} {
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", d, err)
}
}
// Seed two conforming files inside the transmittal folder.
transmittalDir := filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation")
mustWriteHelper(t, filepath.Join(transmittalDir, "Acme-0042_A (RFI) - Foundation.pdf"), "%PDF-")
mustWriteHelper(t, filepath.Join(transmittalDir, "Acme-0042_A (RFI) - Cover Letter.pdf"), "%PDF-")
zddc.InvalidateCache(root)
cfg := config.Config{
Root: root,
EmailHeader: "X-Auth-Request-Email",
MaxWriteBytes: 64 * 1024,
}
do := func(target, email string, body []byte) *httptest.ResponseRecorder {
// target may contain spaces and parens (real transmittal folder
// names do); construct the URL from a url.URL so the request line
// gets properly escaped and r.URL.Path comes back decoded for the
// handler's pattern match.
u := &url.URL{Path: target}
req := httptest.NewRequest(http.MethodPost, u.RequestURI(), bytes.NewReader(body))
req.Header.Set(headerOp, opAcceptTransmittal)
req.Header.Set("Content-Type", "application/yaml")
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
return rec
}
return cfg, do, root
}
// TestAccept_FreshAcceptance — a conforming transmittal folder moves
// from incoming/ to received/, renamed to tracking-only.
func TestAccept_FreshAcceptance(t *testing.T) {
_, do, root := acceptSetup(t)
target := "/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/"
rec := do(target, "alice@example.com", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp acceptResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v; body=%s", err, rec.Body.String())
}
if resp.Tracking != "Acme-0042" {
t.Errorf("Tracking=%q, want Acme-0042", resp.Tracking)
}
if resp.MovedFiles != 2 {
t.Errorf("MovedFiles=%d, want 2", resp.MovedFiles)
}
if resp.Merged {
t.Errorf("Merged=true, want false on fresh acceptance")
}
// Folder should be at received/Acme-0042/, not the transmittal name.
if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Foundation.pdf")); err != nil {
t.Errorf("primary file not moved into received/: %v", err)
}
// Source should no longer exist.
if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation")); !os.IsNotExist(err) {
t.Errorf("source folder still present after rename")
}
}
// TestAccept_NonConformingFilename — a file inside the transmittal
// folder that doesn't parse rejects the whole accept and leaves the
// source untouched.
func TestAccept_NonConformingFilename(t *testing.T) {
_, do, root := acceptSetup(t)
// Drop a bad file alongside the good ones.
mustWriteHelper(t, filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/random-notes.txt"), "oops")
rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil)
if rec.Code != http.StatusConflict {
t.Fatalf("status=%d, want 409; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "random-notes.txt") {
t.Errorf("error body should name the violating file; got %s", rec.Body.String())
}
// Source untouched.
if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation")); err != nil {
t.Errorf("source folder removed despite rejection: %v", err)
}
}
// TestAccept_NonConformingFolderName — a transmittal folder whose
// name doesn't parse rejects with 400 (the URL pattern matches the
// outer shape but the folder grammar fails).
func TestAccept_NonConformingFolderName(t *testing.T) {
_, do, root := acceptSetup(t)
badDir := filepath.Join(root, "Project-1/archive/Acme/incoming/bad-folder-name")
if err := os.MkdirAll(badDir, 0o755); err != nil {
t.Fatal(err)
}
rec := do("/Project-1/archive/Acme/incoming/bad-folder-name/", "alice@example.com", nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
// TestAccept_PlanReviewChain — setup_plan_review: true chains into
// Plan Review and reports both results in the response.
func TestAccept_PlanReviewChain(t *testing.T) {
_, do, root := acceptSetup(t)
body := []byte(strings.Join([]string{
"setup_plan_review: true",
"review_lead: bob@vendor.com",
"approver: carol@example.com",
"plan_review_complete_date: 2026-05-30",
"plan_response_date: 2026-06-15",
"",
}, "\n"))
rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", body)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp acceptResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.PlanReview == nil {
t.Fatalf("PlanReview chain absent in response: %+v", resp)
}
if !resp.PlanReview.Reviewing.Created || !resp.PlanReview.Staging.Created {
t.Errorf("chained Plan Review did not converge: %+v", resp.PlanReview)
}
// received/.zddc must exist (Plan Review writes it).
if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/.zddc")); err != nil {
t.Errorf("received .zddc not written by chained Plan Review: %v", err)
}
}
// TestAccept_Merge — a second acceptance of the same tracking with
// distinct filenames merges into the existing received/<tracking>/
// folder. Re-using a filename is rejected by WORM.
func TestAccept_Merge(t *testing.T) {
_, do, root := acceptSetup(t)
rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil)
if rec.Code != http.StatusOK {
t.Fatalf("first accept status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
// Build a second transmittal folder with the same tracking but a
// distinct rev so the filenames don't collide.
secondDir := filepath.Join(root, "Project-1/archive/Acme/incoming/2026-06-01_Acme-0042 (RFI) - Followup")
if err := os.MkdirAll(secondDir, 0o755); err != nil {
t.Fatal(err)
}
mustWriteHelper(t, filepath.Join(secondDir, "Acme-0042_B (RFI) - Foundation.pdf"), "%PDF-")
rec = do("/Project-1/archive/Acme/incoming/2026-06-01_Acme-0042 (RFI) - Followup/", "alice@example.com", nil)
if rec.Code != http.StatusOK {
t.Fatalf("second accept status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp acceptResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if !resp.Merged {
t.Errorf("Merged=false on re-acceptance of same tracking; want true")
}
// Both revs should now live in received/Acme-0042/.
for _, name := range []string{"Acme-0042_A (RFI) - Foundation.pdf", "Acme-0042_B (RFI) - Foundation.pdf"} {
if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042", name)); err != nil {
t.Errorf("expected %s in merged received/: %v", name, err)
}
}
}

View file

@ -0,0 +1,17 @@
package handler
import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// hasAnyAdminScope reports whether p has EFFECTIVE admin authority
// anywhere in the tree. Returns false for an un-elevated principal
// regardless of what the cascade names — the gate is in zddc.Principal
// itself. For the "could this user opt into admin powers?" question
// (elevation-INDEPENDENT), use zddc.HasAnyAdminGrant directly.
func hasAnyAdminScope(fsRoot string, p zddc.Principal) bool {
if !p.Elevated {
return false
}
return zddc.HasAnyAdminGrant(fsRoot, p.Email)
}

View file

@ -71,7 +71,7 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
if err != nil {
slog.Warn("ACL policy error on resolved file", "path", fileDir, "err", err)
}
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+target); !allowed {
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, PrincipalFromContext(r), "/"+target); !allowed {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
@ -121,7 +121,7 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW
aclCache[fileDir] = false
return false
}
v, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+targetPath)
v, _ := policy.AllowFromChainP(ctx, decider, chain, PrincipalFromContext(r), "/"+targetPath)
aclCache[fileDir] = v
return v
}

View file

@ -151,7 +151,7 @@ func contains(xs []string, x string) bool {
func TestServeArchive_EmptyProject404(t *testing.T) {
root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl:
allow: ["*"]
permissions: {"*": rwcd}
`)
cfg := archiveCfg(root)
@ -170,7 +170,7 @@ func TestServeArchive_EmptyProject404(t *testing.T) {
func TestServeArchive_UnknownProject404(t *testing.T) {
root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl:
allow: ["*"]
permissions: {"*": rwcd}
`)
cfg := archiveCfg(root)
@ -191,7 +191,7 @@ func TestServeArchive_UnknownProject404(t *testing.T) {
func TestServeArchive_ListingScopedToProject(t *testing.T) {
root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl:
allow: ["*"]
permissions: {"*": rwcd}
`)
cfg := archiveCfg(root)
const email = "alice@example.com"
@ -255,7 +255,7 @@ func TestServeArchive_ListingForbiddenWhenUserCanReadNothing(t *testing.T) {
// allow list anywhere → every per-target check returns deny → the
// filtered listing is empty → 403.
writeZddc(t, root, ".", `acl:
allow: ["alice@example.com"]
permissions: {"alice@example.com": rwcd}
`)
cfg := archiveCfg(root)
@ -277,13 +277,13 @@ func TestServeArchive_ListingForbiddenWhenUserCanReadNothing(t *testing.T) {
func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl:
allow: ["*"]
permissions: {"*": rwcd}
`)
// Deny alice on the transmittal folder where 100_~A+C1 lives, so her
// listing of /ProjectA/.archive/ drops that entry — but other ProjectA
// entries stay visible.
writeZddc(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", `acl:
deny: ["alice@example.com"]
permissions: {"alice@example.com": ""}
`)
cfg := archiveCfg(root)
@ -313,10 +313,10 @@ func TestServeArchive_ResolvePerTargetACLOnly(t *testing.T) {
// transmittal folder kicks mallory out at the per-target chain
// ("first explicit match wins, bottom-up").
writeZddc(t, root, ".", `acl:
allow: ["alice@example.com", "mallory@example.com"]
permissions: {"alice@example.com": rwcd, "mallory@example.com": rwcd}
`)
writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl:
deny: ["mallory@example.com"]
permissions: {"mallory@example.com": ""}
`)
cfg := archiveCfg(root)
@ -345,10 +345,10 @@ func TestServeArchive_ResolveBypassesProjectRootDenyWhenPerTargetAllows(t *testi
// — so the per-target chain at the file's directory hits the local
// allow first.
writeZddc(t, root, ".", `acl:
allow: ["alice@example.com"]
permissions: {"alice@example.com": rwcd}
`)
writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl:
allow: ["bob@example.com"]
permissions: {"bob@example.com": rwcd}
`)
cfg := archiveCfg(root)
@ -382,7 +382,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
}
writeZddc(t, root, ".", `acl:
allow: ["*"]
permissions: {"*": rwcd}
`)
cfg := archiveCfg(root)
const email = "alice@example.com"
@ -442,7 +442,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) {
root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl:
allow: ["*@example.com"]
permissions: {"*@example.com": rwcd}
`)
cfg := archiveCfg(root)
@ -464,7 +464,7 @@ func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) {
func TestServeArchive_ListingContentNegotiation(t *testing.T) {
root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl:
allow: ["*"]
permissions: {"*": rwcd}
`)
cfg := archiveCfg(root)
const email = "alice@example.com"

View file

@ -0,0 +1,470 @@
package handler
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// auth_invariants_test.go — behavioral lock-in for the admin/elevation/
// WORM invariants. These tests must pass against the CURRENT code before
// the consolidation refactor (single bypass site in InternalDecider) so
// the refactor can be validated against a green baseline.
//
// Each test covers one invariant called out in the security audit. The
// names are deliberately verbose — when one fails, the failure message
// alone tells you which property got broken.
// invariantsFixture sets up a synthetic ZDDC root with:
//
// - admin@example.com — root super-admin
// - alice@example.com — subtree admin of Project-1/working (via per-dir
// .zddc admins:) — used to test subtree scope
// - bob@example.com — document_controller role member (gets WORM cr
// on received/ + issued/ via cascade defaults)
// - eve@example.com — non-admin, project_team only (read-only across
// the project per defaults)
//
// Plus one file each in working/, issued/, received/ so we can exercise
// reads + writes across the cascade.
func invariantsFixture(t *testing.T) (config.Config, string) {
t.Helper()
root := t.TempDir()
mustWriteHelper(t, filepath.Join(root, ".zddc"),
"admins:\n - admin@example.com\n"+
"roles:\n"+
" document_controller:\n members: [bob@example.com]\n"+
" project_team:\n members: [\"*@example.com\"]\n")
for _, d := range []string{
"Project-1/working/eve@example.com",
"Project-1/archive/Acme/received/Acme-0042",
"Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test",
} {
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", d, err)
}
}
// Subtree-admin grant: alice administers Project-1/working/.
mustWriteHelper(t,
filepath.Join(root, "Project-1/working/.zddc"),
"admins:\n - alice@example.com\n")
// Files to act on.
mustWriteHelper(t,
filepath.Join(root, "Project-1/working/eve@example.com/draft.md"),
"# eve's draft\n")
mustWriteHelper(t,
filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Test.pdf"),
"%PDF-A\n")
mustWriteHelper(t,
filepath.Join(root, "Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md"),
"# issued draft\n")
zddc.InvalidateCache(root)
return config.Config{
Root: root,
EmailHeader: "X-Auth-Request-Email",
MaxWriteBytes: 64 * 1024,
}, root
}
// do executes a request with the given email / elevation flag. URL-encoding
// is computed from the path so spaces and parens (real ZDDC filenames)
// round-trip cleanly.
func doReq(cfg config.Config, method, urlPath, email string, elevated bool, body []byte, op string) *httptest.ResponseRecorder {
u := &url.URL{Path: urlPath}
req := httptest.NewRequest(method, u.RequestURI(), bytes.NewReader(body))
if op != "" {
req.Header.Set(headerOp, op)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, elevated)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
return rec
}
// ── Invariant 1 — Un-elevated admin has no admin authority ────────────────
func TestInvariant_UnelevatedAdminCannotBypassWorm(t *testing.T) {
cfg, _ := invariantsFixture(t)
target := "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md"
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("# mutated\n"), "")
if rec.Code != http.StatusForbidden {
t.Fatalf("un-elevated admin write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestInvariant_UnelevatedAdminCannotEditZddc(t *testing.T) {
// .zddc edits route through the decider as ActionAdmin. The bypass
// for elevated admins fires only when Principal.Elevated is true.
// Exercised at the HTTP boundary: a PUT to .zddc from an un-elevated
// super-admin must return Forbidden.
cfg, _ := invariantsFixture(t)
target := "/Project-1/working/.zddc"
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("title: mutated\n"), "")
if rec.Code != http.StatusForbidden {
t.Fatalf("un-elevated admin .zddc write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestInvariant_ElevatedAdminCanEditZddc(t *testing.T) {
// Positive control: a super-admin who has elevated CAN write any
// .zddc. The decider's IsActiveAdmin short-circuit fires in
// AllowActionFromChainP and the file API write proceeds.
cfg, _ := invariantsFixture(t)
target := "/Project-1/working/.zddc"
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", true, []byte("title: elevated edit\n"), "")
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("elevated admin .zddc write blocked: status=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── Invariant 2 — Elevated admin can do everything (positive control) ─────
func TestInvariant_ElevatedAdminBypassesWorm(t *testing.T) {
cfg, _ := invariantsFixture(t)
target := "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md"
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", true, []byte("# fix-mis-filed\n"), "")
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("elevated admin write blocked: status=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── Invariant 3 — Subtree admin scope ──────────────────────────────────────
func TestInvariant_ElevatedSubtreeAdminWritesInScope(t *testing.T) {
cfg, _ := invariantsFixture(t)
target := "/Project-1/working/eve@example.com/draft.md"
rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# alice override\n"), "")
// alice is subtree admin of Project-1/working/ — should override eve's
// fenced auto-own and write through.
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("elevated subtree admin write in scope blocked: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestInvariant_ElevatedSubtreeAdminBlockedOutsideScope(t *testing.T) {
cfg, _ := invariantsFixture(t)
// alice is subtree admin of /Project-1/working/, NOT of /Project-1/archive/.
target := "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md"
rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# out-of-scope\n"), "")
if rec.Code != http.StatusForbidden {
t.Fatalf("subtree admin escaped scope: status=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── Invariant 4 — .zddc strict-ancestor self-elevation prevention ─────────
// Strict-ancestor was retired — a subtree admin owns their .zddc.
// These tests pin the post-change contract: an elevated admin
// granted in /<dir>/.zddc CAN edit that file (add collaborators,
// adjust ACLs, even — accidentally — remove themselves). Footgun
// is recoverable via super-admin restore.
func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) {
cfg, _ := invariantsFixture(t)
p := zddc.Principal{Email: "alice@example.com", Elevated: true}
dir := filepath.Join(cfg.Root, "Project-1/working")
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
if err != nil {
t.Fatalf("EffectivePolicy: %v", err)
}
if !zddc.IsAdminForChain(chain, p.Email) {
t.Fatalf("subtree admin lost authority to edit own .zddc — strict-ancestor wasn't supposed to apply")
}
}
func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) {
cfg, _ := invariantsFixture(t)
p := zddc.Principal{Email: "alice@example.com", Elevated: true}
dir := filepath.Join(cfg.Root, "Project-1/working/eve@example.com")
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
if err != nil {
t.Fatalf("EffectivePolicy: %v", err)
}
if !zddc.IsAdminForChain(chain, p.Email) {
t.Fatalf("subtree admin blocked from editing deeper .zddc")
}
}
// ── Invariant 5 — Empty email never matches ────────────────────────────────
func TestInvariant_EmptyEmailHasNoAuthority(t *testing.T) {
cfg, _ := invariantsFixture(t)
target := "/Project-1/working/eve@example.com/draft.md"
rec := doReq(cfg, http.MethodPut, target, "", true, []byte("# anon\n"), "")
if rec.Code != http.StatusForbidden && rec.Code != http.StatusUnauthorized {
t.Fatalf("empty-email write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── Invariant 6 — WORM cr survives for document_controller (no admin) ─────
func TestInvariant_DocControllerCanCreateInWormZone(t *testing.T) {
cfg, _ := invariantsFixture(t)
// bob is a document_controller (per role membership) but NOT an admin.
// He must be able to CREATE new files in received/<tracking>/ even
// without elevation — the WORM cr grant carries.
target := "/Project-1/archive/Acme/received/Acme-0042/Acme-0042_B (RFI) - Followup.pdf"
rec := doReq(cfg, http.MethodPut, target, "bob@example.com", false, []byte("%PDF-B\n"), "")
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("doc_controller blocked from WORM create: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestInvariant_DocControllerCannotOverwriteInWormZone(t *testing.T) {
cfg, _ := invariantsFixture(t)
// bob can CREATE in WORM but cannot OVERWRITE — the worm strip
// removes w/d for everyone, even WORM-listed principals.
target := "/Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Test.pdf"
rec := doReq(cfg, http.MethodPut, target, "bob@example.com", false, []byte("%PDF-mutated\n"), "")
if rec.Code != http.StatusForbidden {
t.Fatalf("doc_controller bypassed WORM overwrite-strip: status=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── Invariant 7 — project_team has read but no write ──────────────────────
func TestInvariant_ProjectTeamCanReadCannotWrite(t *testing.T) {
cfg, _ := invariantsFixture(t)
// eve is project_team (r at project level) and the file lives under
// her own working/ home — but she is NOT in any admin list and not
// elevated, so writes must be ACL-gated.
//
// In her own home, eve has auto-own rwcda via the working/<email>/
// auto-own pattern; the cascade gives her create+write there. So
// the right test is a write OUTSIDE her home — into a peer's area
// or into archive.
target := "/Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Test.pdf"
rec := doReq(cfg, http.MethodPut, target, "eve@example.com", false, []byte("# eve overwrite\n"), "")
if rec.Code != http.StatusForbidden {
t.Fatalf("project_team escaped WORM strip: status=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── Invariant 8 — Forward-auth endpoint requires admin membership ─────────
func TestInvariant_ForwardAuthEndpointGatesOnAdminsList(t *testing.T) {
cfg, _ := invariantsFixture(t)
for _, tc := range []struct {
email string
want int
why string
}{
{"admin@example.com", http.StatusOK, "root admin"},
{"alice@example.com", http.StatusForbidden, "subtree admin only — /.auth/admin gates on ROOT admins:, not subtree"},
{"eve@example.com", http.StatusForbidden, "non-admin"},
{"", http.StatusForbidden, "anonymous"},
} {
req := httptest.NewRequest(http.MethodGet, "/.auth/admin", nil)
ctx := context.WithValue(req.Context(), EmailKey, tc.email)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeAuthAdmin(cfg, rec, req)
if rec.Code != tc.want {
t.Errorf("/.auth/admin for %q (%s): got %d, want %d",
tc.email, tc.why, rec.Code, tc.want)
}
}
}
// ── Invariant 10 — .zddc write matrix at root / project / subtree ─────────
// TestInvariant_ZddcPutMatrix exercises every (principal × elevation ×
// target) combination for PUT to a .zddc file. The decider's
// IsActiveAdmin short-circuit is the single bypass; this matrix locks
// down that it only fires for an Elevated principal who is named in
// the admins: list of some level on the target's chain.
//
// Targets:
// - /.zddc — root file (root admins: govern)
// - /Project-1/.zddc — project file (no on-disk .zddc;
// write must materialise it; root
// admins still govern via cascade)
// - /Project-1/working/.zddc — subtree file; alice administers
// this subtree via its own admins:
// list (so alice's write doesn't
// require root-admin authority).
//
// Expected status: 200 or 201 on success; 403 on denial; 404 only when
// resolveTargetPath rejects the path (e.g. empty email gets 403 from
// the decider, not 404).
func TestInvariant_ZddcPutMatrix(t *testing.T) {
type principal struct {
email string
elevated bool
}
rootAdminElevated := principal{"admin@example.com", true}
rootAdminUnelevated := principal{"admin@example.com", false}
subtreeAdminElevated := principal{"alice@example.com", true}
subtreeAdminUnelevated := principal{"alice@example.com", false}
nonAdmin := principal{"eve@example.com", true}
anon := principal{"", true}
const (
ok = http.StatusOK
den = http.StatusForbidden
)
cases := []struct {
name string
target string
who principal
want int
}{
// Root .zddc
{"root admin elevated → root .zddc", "/.zddc", rootAdminElevated, ok},
{"root admin un-elevated → root .zddc", "/.zddc", rootAdminUnelevated, den},
{"subtree admin elevated → root .zddc", "/.zddc", subtreeAdminElevated, den},
{"subtree admin un-elevated → root .zddc", "/.zddc", subtreeAdminUnelevated, den},
{"non-admin → root .zddc", "/.zddc", nonAdmin, den},
{"anonymous → root .zddc", "/.zddc", anon, den},
// Project .zddc (no on-disk file yet — PUT creates it)
{"root admin elevated → project .zddc", "/Project-1/.zddc", rootAdminElevated, http.StatusCreated},
{"root admin un-elevated → project .zddc", "/Project-1/.zddc", rootAdminUnelevated, den},
{"subtree admin elevated (out-of-scope) → project .zddc", "/Project-1/.zddc", subtreeAdminElevated, den},
{"non-admin → project .zddc", "/Project-1/.zddc", nonAdmin, den},
// Subtree .zddc (alice administers this subtree)
{"root admin elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminElevated, ok},
{"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, den},
{"subtree admin elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, ok},
{"subtree admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, den},
{"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, den},
{"anonymous → subtree .zddc", "/Project-1/working/.zddc", anon, den},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg, _ := invariantsFixture(t)
body := []byte("title: matrix probe\n")
rec := doReq(cfg, http.MethodPut, tc.target, tc.who.email, tc.who.elevated, body, "")
if tc.want == den {
if rec.Code != http.StatusForbidden && rec.Code != http.StatusUnauthorized {
t.Fatalf("want denial, got %d body=%s", rec.Code, dumpBody(rec))
}
} else if rec.Code != tc.want {
t.Fatalf("want %d, got %d body=%s", tc.want, rec.Code, dumpBody(rec))
}
})
}
}
// TestInvariant_ZddcDeleteMatrix mirrors ZddcPutMatrix for DELETE. The
// project-level .zddc target is dropped (no on-disk file → 404 lives
// outside the auth surface). The cases that remain pin: only an
// elevated admin with authority over the .zddc's directory can drop
// the file.
func TestInvariant_ZddcDeleteMatrix(t *testing.T) {
type principal struct {
email string
elevated bool
}
rootAdminElevated := principal{"admin@example.com", true}
rootAdminUnelevated := principal{"admin@example.com", false}
subtreeAdminElevated := principal{"alice@example.com", true}
subtreeAdminUnelevated := principal{"alice@example.com", false}
nonAdmin := principal{"eve@example.com", true}
cases := []struct {
name string
target string
who principal
want int
}{
{"root admin elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminElevated, http.StatusNoContent},
{"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, http.StatusForbidden},
{"subtree admin elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, http.StatusNoContent},
{"subtree admin un-elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden},
{"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg, _ := invariantsFixture(t)
rec := doReq(cfg, http.MethodDelete, tc.target, tc.who.email, tc.who.elevated, nil, "")
if rec.Code != tc.want {
t.Fatalf("want %d, got %d body=%s", tc.want, rec.Code, dumpBody(rec))
}
})
}
}
// ── Invariant 11 — anti-bypass: un-elevated admin gets nothing extra ──────
// TestInvariant_UnelevatedAdminNoSilentBypass is the anti-test for the
// elevation gate. For every (admin-flavour × action) tuple, an
// un-elevated admin must behave exactly like a non-admin: they may
// only do what an explicit ACL grant permits. The fixture's admin and
// alice both have NO baseline ACL grant outside their admin scope, so
// every action below MUST 403 — any pass indicates a bypass leak.
func TestInvariant_UnelevatedAdminNoSilentBypass(t *testing.T) {
cfg, _ := invariantsFixture(t)
type op struct {
method string
path string
body []byte
op string
}
probes := []op{
// .zddc writes (ActionAdmin)
{http.MethodPut, "/.zddc", []byte("title: x\n"), ""},
{http.MethodPut, "/Project-1/working/.zddc", []byte("title: x\n"), ""},
{http.MethodDelete, "/Project-1/working/.zddc", nil, ""},
// WORM writes (ActionWrite / ActionCreate stripped)
{http.MethodPut, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", []byte("# mutate\n"), ""},
{http.MethodPut, "/Project-1/archive/Acme/received/Acme-0042/new.pdf", []byte("%PDF\n"), ""},
{http.MethodDelete, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", nil, ""},
// Regular write into someone else's working/ home (no ACL grant)
{http.MethodPut, "/Project-1/working/eve@example.com/draft.md", []byte("# steal\n"), ""},
}
admins := []struct {
name string
email string
}{
{"root super-admin", "admin@example.com"},
{"subtree admin (alice)", "alice@example.com"},
}
for _, a := range admins {
for _, p := range probes {
t.Run(a.name+" "+p.method+" "+p.path, func(t *testing.T) {
rec := doReq(cfg, p.method, p.path, a.email, false, p.body, p.op)
if rec.Code != http.StatusForbidden {
t.Fatalf("BYPASS LEAK: %s un-elevated reached %s %s with status %d body=%s",
a.email, p.method, p.path, rec.Code, dumpBody(rec))
}
})
}
}
}
// ── Invariant 9 — Profile admin endpoints 404 (not 403) for non-admins ────
func TestInvariant_ProfileAdminEndpointsHideFromNonAdmins(t *testing.T) {
// These checks lock in the existence-hiding property: non-admins must
// see 404, never 403, so they can't probe which paths exist.
t.Skip("requires the profile handler dispatcher entry point; skip until the refactor confirms ServeProfile signature")
}
// dump prints the rec body when t.Logf would help debugging — used in
// failure messages to avoid silently empty 403 cases.
func dumpBody(rec *httptest.ResponseRecorder) string {
s := rec.Body.String()
return strings.TrimSpace(s)
}

View file

@ -32,14 +32,21 @@ const AuthPathPrefix = "/.auth"
// noticeable overhead.
//
// Scope: gates ON ROOT-ADMIN STATUS ONLY. This is intentionally
// stricter than the regular acl.allow / acl.deny chain — admin-only
// stricter than the regular acl.permissions chain — admin-only
// endpoints (the dev-shell IDE, future maintenance routes) shouldn't
// fall through to subtree-level allowances. For per-route ACL, callers
// continue using the existing handlers (archive, profile, etc.) which
// consult AllowedWithChain.
// consult the policy decider.
func ServeAuthAdmin(cfg config.Config, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if email == "" || !zddc.IsAdmin(cfg.Root, email) {
// Elevation-independent gate. Upstream proxies (Caddy forward_auth
// for the dev-shell IDE) call this from a different cookie scope
// than the zddc-server origin, so the elevation cookie can't reach
// here even when the user has it set. This is a coarse "is this
// email a root admin?" check, not a per-action authority decision —
// construct a synthetically-elevated Principal so the underlying
// admin check evaluates the admins: list as usual.
if email == "" || !zddc.IsAdmin(cfg.Root, zddc.Principal{Email: email, Elevated: true}) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

View file

@ -32,7 +32,7 @@ func TestServeAuthAdmin(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := requestWithEmail(http.MethodGet, AuthPathPrefix+"/admin", tc.email)
req := requestAsAdmin(http.MethodGet, AuthPathPrefix+"/admin", tc.email)
rec := httptest.NewRecorder()
ServeAuthAdmin(cfg, rec, req)
if rec.Code != tc.wantStatus {
@ -53,7 +53,7 @@ func TestServeAuthAdmin_NoZddcRootDeniesEverything(t *testing.T) {
cfg, _ := profileTestRoot(t, nil)
for _, email := range []string{"", "alice@example.com", "anyone@example.com"} {
req := requestWithEmail(http.MethodGet, AuthPathPrefix+"/admin", email)
req := requestAsAdmin(http.MethodGet, AuthPathPrefix+"/admin", email)
rec := httptest.NewRecorder()
ServeAuthAdmin(cfg, rec, req)
if rec.Code != http.StatusForbidden {

View file

@ -18,13 +18,19 @@ import (
// On-demand MD→{docx,html,pdf} conversion endpoint.
//
// GET /<path>/foo.md?convert=docx|html|pdf
// GET /<path>/foo.docx (or .html / .pdf)
//
// The source file's read policy (already enforced by the dispatcher
// before this handler runs) gates the response. The converted bytes
// are cached at <dir>/.converted/<base>.<ext>, with mtime synced to the
// source — so a fast-path GET that finds a fresh cache hit serves the
// disk file via http.ServeContent without invoking pandoc at all.
// The URL is the rendered form of a sibling `foo.md` source. The
// dispatcher recognises the pattern via RecognizeVirtualConvert when
// a stat on `foo.docx` (etc.) fails AND `foo.md` exists; only then is
// ServeConverted invoked. A real on-disk `foo.docx` wins precedence
// and serves its bytes normally.
//
// The source file's read policy (enforced by the dispatcher before this
// handler runs) gates the response. The converted bytes are cached at
// <dir>/.converted/<base>.<ext>, with mtime synced to the source — so a
// fast-path GET that finds a fresh cache hit serves the disk file via
// http.ServeContent without invoking pandoc at all.
//
// When the cache is stale (or absent) the handler:
// 1. Reads source bytes.
@ -42,6 +48,39 @@ var convertSF singleflightGroup
// runner itself enforces a finer-grained timeout on the container.
const convertTimeout = 90 * time.Second
// RecognizeVirtualConvert reports whether urlPath names a virtual
// "<file>.<format>" — a rendered form of a sibling markdown source.
// Returns (mdAbsPath, format, true) when <file>.md exists on disk and
// the requested extension is one of docx / html / pdf. The caller
// (the dispatcher) only invokes this when a stat on the requested
// path itself fails — a real on-disk file always wins.
//
// A virtual file URL means `<a href="…/foo.docx">` works without any
// query-string handling, and a script's `curl -O …/foo.pdf` writes the
// expected filename.
func RecognizeVirtualConvert(fsRoot, urlPath string) (mdAbs, format string, ok bool) {
lower := strings.ToLower(urlPath)
for _, ext := range []string{".docx", ".html", ".pdf"} {
if !strings.HasSuffix(lower, ext) {
continue
}
base := urlPath[:len(urlPath)-len(ext)]
if base == "" || strings.HasSuffix(base, "/") {
continue
}
rel := strings.Trim(base, "/") + ".md"
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
// Path containment.
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
continue
}
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
return abs, ext[1:], true
}
}
return "", "", false
}
// ServeConverted is the entry point. format is the requested target
// extension; chain is the already-resolved ACL chain (re-used here
// only to extract the convert: cascade metadata).
@ -58,7 +97,7 @@ func ServeConverted(cfg config.Config, w http.ResponseWriter, r *http.Request, s
if !ok {
// One re-probe attempt — gives the operator a way to recover
// after building the image without restarting the server.
caps = convert.Reprobe(r.Context(), os.Getenv("ZDDC_CONVERT_ENGINE"))
caps = convert.Reprobe(r.Context())
if !caps.Ready() {
w.Header().Set("Retry-After", "60")
http.Error(w, "Service Unavailable — "+caps.Reason(), http.StatusServiceUnavailable)

View file

@ -0,0 +1,69 @@
# Default project-rollup Master Deliverables List spec, served by
# zddc-server when no operator-supplied table.yaml exists at
# <project>/mdl/.
#
# This view aggregates every deliverable row from every party under
# <project>/archive/. Each synthetic row is backed by the real file
# at <project>/archive/<party>/mdl/<file>.yaml; the leading `party`
# column is derived from the row's source folder (path-injected by
# the server, not stored in the YAML).
#
# + Add row is suppressed in this view because the party affiliation
# would be ambiguous — add deliverables at the per-party path
# (<project>/archive/<party>/mdl/) and they'll appear here on next
# load.
title: Project Deliverables (all parties)
description: Every deliverable across all parties under archive/. Click a row to edit; add rows at the per-party MDL view.
addable: false
columns:
- field: party
title: Package
width: 7em
- field: originator
title: Originator
width: 8em
- field: phase
title: Phase
width: 5em
- field: project
title: Project
width: 8em
- field: area
title: Area
width: 5em
- field: discipline
title: Disc.
width: 5em
- field: type
title: Type
width: 6em
- field: sequence
title: Seq.
width: 5em
- field: suffix
title: Suffix
width: 5em
- field: title
title: Deliverable
- field: plannedRevision
title: Rev.
width: 5em
- field: plannedDate
title: Planned
format: date
width: 8em
- field: status
title: Status
width: 6em
enum: [DFT, IFR, IFA, IFC, AFC, AB]
- field: owner
title: Owner
width: 12em
defaults:
sort:
- { field: party, dir: asc }
- { field: plannedDate, dir: asc }

View file

@ -0,0 +1,56 @@
# Default project-rollup Risk Register spec, served by zddc-server
# when no operator-supplied table.yaml exists at <project>/rsk/.
#
# This view aggregates every risk row from every party under
# <project>/archive/. Each synthetic row is backed by the real file
# at <project>/archive/<party>/rsk/<file>.yaml; the leading `party`
# column is derived from the row's source folder (path-injected by
# the server, not stored in the YAML).
#
# + Add row is suppressed in this view because the party affiliation
# would be ambiguous — add risks at the per-party path
# (<project>/archive/<party>/rsk/) and they'll appear here on next
# load.
title: Project Risk Register (all parties)
description: Every risk across all parties under archive/. Click a row to edit; add rows at the per-party RSK view.
addable: false
columns:
- field: party
title: Package
width: 7em
- field: id
title: ID
width: 6em
- field: title
title: Risk
- field: category
title: Category
width: 10em
- field: likelihood
title: L
width: 4em
- field: impact
title: I
width: 4em
- field: severity
title: Sev
width: 5em
- field: owner
title: Owner
width: 12em
- field: status
title: Status
width: 9em
enum: [open, mitigated, accepted, closed]
- field: dueDate
title: Due
format: date
width: 8em
defaults:
sort:
- { field: severity, dir: desc }
- { field: party, dir: asc }

View file

@ -0,0 +1,83 @@
# Default row schema for a Risk Register entry, served by
# zddc-server when no operator-supplied form.yaml exists at
# archive/<party>/rsk/.
#
# Likelihood and impact use the standard 1-5 ordinal scales;
# severity is also 1-25 (typically L*I) and stored on each row so
# operators can override it when the simple product doesn't capture
# the actual risk profile.
#
# To customize: drop your own form.yaml into archive/<party>/rsk/
# (the same directory as table.yaml). Tighten constraints with
# `enum:`, `pattern:`, etc. Add fields and they'll appear in the
# row-edit form; add a matching column to table.yaml to surface
# the field in the table view too.
title: Risk
description: One identified risk. Likelihood and impact use 1-5 ordinals; severity is stored separately so it can be overridden when L*I underrepresents the residual exposure.
schema:
type: object
required: [id, title]
additionalProperties: false
properties:
id:
type: string
title: ID
description: Stable identifier, e.g. R-001.
minLength: 1
title:
type: string
title: Risk
minLength: 1
category:
type: string
title: Category
description: Free-form grouping (schedule, cost, technical, regulatory, ...).
description:
type: string
title: Description
likelihood:
type: integer
title: Likelihood
description: 1 (rare) to 5 (almost certain).
minimum: 1
maximum: 5
impact:
type: integer
title: Impact
description: 1 (negligible) to 5 (catastrophic).
minimum: 1
maximum: 5
severity:
type: integer
title: Severity
description: Residual risk score. Typically likelihood * impact (1-25), but operators can override.
minimum: 1
maximum: 25
mitigation:
type: string
title: Mitigation
description: Plan for reducing this risk's likelihood or impact.
owner:
type: string
title: Owner
description: Email or party name responsible for tracking this risk.
status:
type: string
title: Status
enum: [open, mitigated, accepted, closed]
dueDate:
type: string
title: Due date
format: date
notes:
type: string
title: Notes
ui:
description:
ui:widget: textarea
mitigation:
ui:widget: textarea
notes:
ui:widget: textarea

View file

@ -0,0 +1,51 @@
# Default Risk Register spec, served by zddc-server when no
# operator-supplied table.yaml exists at archive/<party>/rsk/.
#
# Columns cover the standard risk-register fields: identifier, title,
# category, likelihood / impact / severity scores, owner, status, and
# due date. Severity is stored on each row (1-25, typically L*I) so
# operators can override it when the simple product doesn't capture
# the actual risk profile.
#
# To customize: drop your own table.yaml + form.yaml into the same
# directory (archive/<party>/rsk/). The whole directory IS the table —
# spec, row-edit form, and rows are siblings. Override examples mirror
# the MDL table.yaml customization patterns.
title: Risk Register
description: Risks tracked for this party. Severity is the residual risk score; sort defaults to severity descending.
columns:
- field: id
title: ID
width: 6em
- field: title
title: Risk
- field: category
title: Category
width: 10em
- field: likelihood
title: L
width: 4em
- field: impact
title: I
width: 4em
- field: severity
title: Sev
width: 5em
- field: owner
title: Owner
width: 12em
- field: status
title: Status
width: 9em
enum: [open, mitigated, accepted, closed]
- field: dueDate
title: Due
format: date
width: 8em
defaults:
sort:
- { field: severity, dir: desc }
- { field: dueDate, dir: asc }

View file

@ -0,0 +1,76 @@
# Default row schema for a Supplier / Subcontractor Status Report
# entry, served by zddc-server when no operator-supplied form.yaml
# exists at <project>/archive/<party>/ssr.form.yaml.
#
# The `name` field doubles as the party folder name (the row's
# stable identifier). It's required on create (+ Add row materializes
# <project>/archive/<name>/) but is stripped from the YAML on save —
# the folder name IS the identity, so storing it inside the file too
# would just be a denormalization. On read the dispatcher injects
# name back into the row data so this form (and the SSR table)
# can display it.
#
# Pattern excludes leading `.` and `_` to avoid colliding with
# fileapi.go's dot/underscore-prefix guards on file paths.
#
# To customize: drop your own form.yaml into
# <project>/archive/<party>/ (sibling to the party's ssr.yaml).
title: Supplier / Subcontractor Status
description: One party's status report. The party name doubles as the archive folder name and is required when creating a new row.
schema:
type: object
required: [name, vendorType, contractNo, scopeSummary]
additionalProperties: false
properties:
name:
type: string
title: Party (folder name)
description: Becomes <project>/archive/<name>/. Typical naming = MasterFormat 4-digit code + C|P + sequence digit (e.g. 0330C1).
pattern: "^[A-Za-z0-9][A-Za-z0-9.-]*$"
minLength: 1
vendorType:
type: string
title: Vendor type
enum: [subcontractor, supplier, consultant, vendor, other]
contractNo:
type: string
title: Contract / PO number
scopeSummary:
type: string
title: Scope summary
contractValue:
type: number
title: Contract value
awardDate:
type: string
title: Award date
format: date
kickoffDate:
type: string
title: Kickoff date
format: date
scheduleStatus:
type: string
title: Schedule status
enum: [on-track, at-risk, behind, completed, on-hold]
deliverablesStatus:
type: string
title: Deliverables status
enum: [on-track, at-risk, behind, completed]
paymentStatus:
type: string
title: Payment status
enum: [current, overdue, hold, complete]
ownerContact:
type: string
title: Owner contact (email)
notes:
type: string
title: Notes
ui:
scopeSummary:
ui:widget: textarea
notes:
ui:widget: textarea

View file

@ -0,0 +1,62 @@
# Default Supplier / Subcontractor Status Report spec, served by
# zddc-server when no operator-supplied table.yaml exists at
# <project>/ssr/.
#
# The SSR is a project-level aggregation: one row per party folder
# under <project>/archive/, each row backed by
# <project>/archive/<party>/ssr.yaml. The synthetic `name` column
# shows the party folder name (which is the row's stable identifier);
# typical naming encodes a MasterFormat 4-digit code plus C|P plus
# a sequence digit (e.g. 0330C1, 0440P2).
#
# To customize: drop your own table.yaml + form.yaml at
# <project>/ssr/table.yaml + form.yaml (the cascade declares
# <project>/ssr/ as virtual, but the spec files themselves can be
# real overrides). Add columns or tighten enums as your project's
# subcontract reporting requires.
title: Supplier / Subcontractor Status
description: One row per party folder under archive/. Click + Add row to create a new party (folder + metadata).
columns:
- field: name
title: Party
width: 8em
- field: vendorType
title: Type
width: 9em
- field: contractNo
title: Contract
width: 10em
- field: scopeSummary
title: Scope
- field: contractValue
title: Value
width: 10em
- field: awardDate
title: Award
format: date
width: 8em
- field: kickoffDate
title: Kickoff
format: date
width: 8em
- field: scheduleStatus
title: Schedule
width: 9em
enum: [on-track, at-risk, behind, completed, on-hold]
- field: deliverablesStatus
title: Deliv.
width: 9em
enum: [on-track, at-risk, behind, completed]
- field: paymentStatus
title: Pmt.
width: 8em
enum: [current, overdue, hold, complete]
- field: ownerContact
title: Owner contact
width: 14em
defaults:
sort:
- { field: name, dir: asc }

View file

@ -77,7 +77,7 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
}
isRoot := dirPath == ""
if !isRoot {
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, urlPath); !allowed {
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -117,7 +117,7 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
// used by ?zip and ?convert= elsewhere in the dispatcher.
includeHidden := r.URL.Query().Has("hidden")
entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL, includeHidden)
entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL, includeHidden, ElevatedFromContext(r))
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "Not Found", http.StatusNotFound)
@ -147,6 +147,22 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
if dt := zddc.DefaultToolAt(cfg.Root, absDir); dt != "" {
w.Header().Set("X-ZDDC-Default-Tool", dt)
}
// X-ZDDC-On-Plan-Review surfaces whether the cascade above this
// path has an on_plan_review block configured. Browse uses it to
// show/hide the "Plan Review" right-click menu item without
// re-implementing the cascade client-side. Boolean; absent header
// = false.
if zddc.OnPlanReviewAt(cfg.Root, absDir) != nil {
w.Header().Set("X-ZDDC-On-Plan-Review", "true")
}
// X-ZDDC-Canonical-Folder names the canonical project-layout slot
// this directory occupies — "incoming", "received", "working",
// "staging", etc. Drives scope-aware context-menu visibility for
// Accept Transmittal, Stage/Unstage, and Create Transmittal folder.
// Absent header means the directory is not at a canonical slot.
if cf := zddc.CanonicalFolderAt(cfg.Root, absDir); cf != "" {
w.Header().Set("X-ZDDC-Canonical-Folder", cf)
}
if strings.Contains(accept, "application/json") {
// Content-hash ETag on the listing payload. Re-fetched on every

View file

@ -29,7 +29,7 @@ func TestServeDirectoryRootIsPublic(t *testing.T) {
// nothing else. A user without that email would have been 403'd before
// the bypass.
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("admins:\n - admin@example.com\nacl:\n allow:\n - admin@example.com\n"),
[]byte("admins:\n - admin@example.com\nacl:\n permissions:\n admin@example.com: rwcd\n"),
0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
@ -41,11 +41,11 @@ func TestServeDirectoryRootIsPublic(t *testing.T) {
}
}
if err := os.WriteFile(filepath.Join(root, "PublicProj", ".zddc"),
[]byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
[]byte("acl:\n permissions:\n \"*\": rwcd\n"), 0o644); err != nil {
t.Fatalf("write PublicProj .zddc: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "PrivateProj", ".zddc"),
[]byte("acl:\n allow: [admin@example.com]\n"), 0o644); err != nil {
[]byte("acl:\n permissions:\n admin@example.com: rwcd\n"), 0o644); err != nil {
t.Fatalf("write PrivateProj .zddc: %v", err)
}

View file

@ -50,6 +50,9 @@ const (
opMove = "move"
opMkdir = "mkdir"
// opSSRRename / opPlanReview / opAcceptTransmittal are declared
// alongside their handler files. Listed in the dispatch switch
// below so they're discoverable from a single place.
)
// IsWriteMethod reports whether this method is handled by the file API.
@ -96,10 +99,16 @@ func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL str
// Reject hidden / reserved segments. Mirrors dispatch's guard,
// applied here too because external callers reach ServeFileAPI
// only via dispatch — but defense in depth costs nothing.
for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") {
// Carve-out: `.zddc` as a leaf segment is writable (admin-gated)
// via the file API. Other dot/underscore segments stay reserved.
segs := strings.Split(strings.Trim(cleanURL, "/"), "/")
for i, seg := range segs {
if seg == "" {
continue
}
if seg == ZddcFileBasename && i == len(segs)-1 {
continue
}
if seg == "_app" || strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") {
return "", "", false, http.StatusNotFound, "reserved path segment"
}
@ -118,17 +127,12 @@ func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL str
// that create a brand-new file inherit the parent directory's chain).
// Returns allowed=false with the response status already written on deny.
//
// Admin escape hatches: root admins (IsAdmin) and subtree admins
// (IsSubtreeAdmin) get unconditional access — the cascade evaluator
// and the WORM mask do not see their requests at all. This matches
// the existing admin-bypass semantics in /.profile/zddc and is the
// only way to mutate filed documents in Issued/Received.
//
// .zddc writes use the stricter CanEditZddc rule (strict-ancestor
// admin authority) regardless of the action verb, since the file
// being written is itself the source of the authority decision and
// the strict-ancestor rule is the existing defense against
// self-elevation.
// All admin / WORM / ACL logic lives downstream in the decider's single
// bypass site (policy.InternalDecider.Allow). AllowActionFromChainP
// computes IsActiveAdmin from the chain and Principal.Elevated, with
// the strict-ancestor rule applied when action == ActionAdmin (the
// caller tags .zddc writes that way). The handler does NOT make
// admin/elevation decisions of its own — one bypass site, one helper.
func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request, absPath, urlPath, action string) bool {
probe := filepath.Dir(absPath)
for {
@ -143,39 +147,14 @@ func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request,
probe = filepath.Dir(probe)
}
email := EmailFromContext(r)
// Admin bypass — root and subtree.
if zddc.IsAdmin(cfg.Root, email) {
return true
}
if zddc.IsSubtreeAdmin(cfg.Root, probe, email) {
return true
}
// .zddc writes: CanEditZddc enforces the strict-ancestor rule that
// prevents a subtree admin from elevating themselves by editing the
// .zddc that grants their authority. Non-admins fall through to the
// regular decider — they will be denied unless an explicit `a` verb
// is granted to a non-admin role at this path, which is unusual.
if filepath.Base(absPath) == ".zddc" {
zddcDir := filepath.Dir(absPath)
if zddc.CanEditZddc(cfg.Root, zddcDir, email) {
return true
}
// Non-admin .zddc writes go through the normal cascade with
// action=admin. Most deployments will have no acl.permissions
// entry granting `a`, so this denies; operators who want
// non-admin .zddc edits can grant `a` explicitly.
}
p := PrincipalFromContext(r)
chain, err := zddc.EffectivePolicy(cfg.Root, probe)
if err != nil {
slog.Warn("file API ACL chain error", "path", absPath, "err", err)
}
decider := DeciderFromContext(r)
allowed, _ := policy.AllowActionFromChain(r.Context(), decider, chain, email, urlPath, action)
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action)
if !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return false
@ -321,6 +300,65 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return
}
// Virtual project-level table views — SSR / MDL rollup / RSK
// rollup. The PUT URL lives in <project>/{ssr,mdl,rsk}/...; the
// underlying bytes belong inside <project>/archive/<party>/. We
// rewrite abs + cleanURL to the canonical path so the rest of
// this function (ACL gate, ETag, audit, conversion-cache purge)
// operates on the real file location.
//
// SSR row PUTs land at archive/<party>/ssr.yaml; MDL/RSK rollup
// row PUTs land at archive/<party>/<slot>/<file>.yaml. Same
// shape as the virtual-received rewrite below.
if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() {
abs = vv.CanonicalAbs
cleanURL = vv.CanonicalURL
w.Header().Set("X-ZDDC-Resolved-Path", cleanURL)
}
// Virtual received/ rewrite. When the PUT targets a file under the
// synthetic <workflow>/received/<file> URL, the canonical record is
// WORM — we can't write there. Convention: treat the drop as a
// review comment, write it into the workflow folder as
// <base>+C<n><suffix> where n increments past any existing comments
// on the same target. The target filename comes from the URL's
// final segment.
if vr := zddc.ResolveVirtualReceived(cfg.Root, cleanURL); vr.Resolved && !vr.IsRoot {
targetName := filepath.Base(vr.SuffixURL)
commentName, cerr := zddc.CommentResolvedName(vr.WorkflowAbs, targetName)
if cerr != nil {
http.Error(w, "Bad Request — comment upload requires a ZDDC-parseable target filename: "+cerr.Error(), http.StatusBadRequest)
return
}
// Race-fix: if the computed filename already exists (concurrent
// upload), step the counter forward until we find a free slot.
abs = filepath.Join(vr.WorkflowAbs, commentName)
for i := 0; i < 32; i++ {
if _, err := os.Stat(abs); errors.Is(err, os.ErrNotExist) {
break
} else if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Bump: recompute with one more existing sibling.
commentName, cerr = zddc.CommentResolvedName(vr.WorkflowAbs, targetName)
if cerr != nil {
http.Error(w, "Internal Server Error — comment counter: "+cerr.Error(), http.StatusInternalServerError)
return
}
abs = filepath.Join(vr.WorkflowAbs, commentName)
}
// Rewrite cleanURL so audit logs + response headers reflect
// the actual destination, not the virtual one. Surface to the
// client via X-ZDDC-Resolved-Path so the status line can show
// "Saved as <resolved name>".
cleanURL = vr.WorkflowURL + commentName
w.Header().Set("X-ZDDC-Resolved-Path", cleanURL)
// Continue with normal write flow — ACL on the workflow folder
// gates the write, and existed=false (new file) selects
// ActionCreate.
}
// Resolve canonical-folder casing on the way in (no side effects): a
// request for /Project/working/foo.md when the on-disk folder is
// Working/ should land in Working/, not create a duplicate sibling.
@ -405,6 +443,21 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
return
}
// Virtual project-level table views. SSR row deletes are refused
// (would orphan the party folder and its mdl/rsk contents) — use
// the archive view to delete a party. MDL/RSK rollup row deletes
// pass through to the canonical archive/<party>/<slot>/<file>.yaml
// path with the normal ACL gate.
if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() {
if vv.Kind == zddc.VirtualViewSSRRow {
http.Error(w, "Method Not Allowed — delete the party folder via the archive view, not the SSR table", http.StatusMethodNotAllowed)
return
}
abs = vv.CanonicalAbs
cleanURL = vv.CanonicalURL
w.Header().Set("X-ZDDC-Resolved-Path", cleanURL)
}
info, err := os.Stat(abs)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
@ -450,6 +503,12 @@ func serveFilePost(cfg config.Config, w http.ResponseWriter, r *http.Request) {
serveFileMove(cfg, w, r)
case opMkdir:
serveFileMkdir(cfg, w, r)
case opPlanReview:
servePlanReview(cfg, w, r)
case opAcceptTransmittal:
serveAcceptTransmittal(cfg, w, r)
case opSSRRename:
serveSSRRename(cfg, w, r)
case "":
http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest)
default:

View file

@ -32,7 +32,7 @@ func fileAPITestSetup(t *testing.T, dirs []string, seed map[string]string) (cfg
// Root .zddc grants writer access to *@example.com.
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n allow:\n - \"*@example.com\"\n deny: []\n"), 0o644); err != nil {
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
@ -69,6 +69,7 @@ func fileAPITestSetup(t *testing.T, dirs []string, seed map[string]string) (cfg
req.Header.Set(k, v)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
@ -137,7 +138,7 @@ func TestFileAPI_PutDenyForbidden(t *testing.T) {
// Tighten ACL to a different domain — alice@example.com no longer
// matches and writes must be 403.
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"),
[]byte("acl:\n allow:\n - \"*@allowed.com\"\n deny: []\n"), 0o644); err != nil {
[]byte("acl:\n permissions:\n \"*@allowed.com\": rwcd\n"), 0o644); err != nil {
t.Fatalf("rewrite .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
@ -151,7 +152,10 @@ func TestFileAPI_PutDenyForbidden(t *testing.T) {
func TestFileAPI_PutHiddenSegmentRejected(t *testing.T) {
_, do, _ := fileAPITestSetup(t, nil, nil)
for _, p := range []string{"/.zddc", "/foo/.hidden", "/_app/spoof.html", "/_template/x"} {
// .zddc as a leaf is carved out — gated on admin authority via the
// decider, not blocked at the segment guard. Every other dot/
// underscore segment stays reserved.
for _, p := range []string{"/foo/.hidden", "/_app/spoof.html", "/_template/x", "/.zddc.d/x"} {
rec := do(http.MethodPut, p, "alice@example.com", []byte("x"), nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404 for %s, got %d", p, rec.Code)
@ -166,6 +170,7 @@ func TestFileAPI_PutOversizeRejected(t *testing.T) {
body := bytes.Repeat([]byte("A"), 32)
req := httptest.NewRequest(http.MethodPut, "/Incoming/big.bin", bytes.NewReader(body))
ctx := context.WithValue(req.Context(), EmailKey, "alice@example.com")
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
@ -435,7 +440,6 @@ acl:
Root: root,
EmailHeader: "X-Auth-Request-Email",
MaxWriteBytes: 1024 * 1024,
CascadeMode: "delegated",
}
decider := &policy.InternalDecider{}
@ -450,6 +454,7 @@ acl:
req.Header.Set(k, v)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
ctx = context.WithValue(ctx, DeciderKey, decider)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
@ -625,45 +630,6 @@ func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
}
}
func TestFileAPI_StrictMode_AncestorDenyAbsolute(t *testing.T) {
cfg, _, root := rolePermissionsTestSetup(t)
cfg.CascadeMode = "strict"
// Add a strict-mode lockout at root: deny vendor_acme everywhere.
rootZ, _ := os.ReadFile(filepath.Join(root, ".zddc"))
updated := strings.Replace(string(rootZ), "_doc_controller: rwcda\n",
"_doc_controller: rwcda\n vendor_acme: \"\"\n", 1)
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(updated), 0o644); err != nil {
t.Fatalf("rewrite root: %v", err)
}
zddc.InvalidateCache(root)
// Build a strict-mode decider so the file API uses the new mode.
decider := &policy.InternalDecider{Mode: zddc.ModeStrict}
doStrict := func(method, target, email string, body []byte) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, DeciderKey, decider)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
return rec
}
// Vendor's leaf rwcd grant in archive/Acme/.zddc is overridden by
// the root deny under strict mode.
rec := doStrict(http.MethodPut, "/Project-X/archive/Acme/incoming/blocked.pdf", "rep@acme.com", []byte("nope"))
if rec.Code != http.StatusForbidden {
t.Fatalf("strict mode: vendor should be denied by root explicit-deny, got %d: %s", rec.Code, rec.Body.String())
}
}
// --- staging↔working mirror -------------------------------------------------
// stagingMirrorURL builds a URL-safe target path for a transmittal folder

View file

@ -77,16 +77,21 @@ type formContext struct {
// FormRequest describes a recognized form-system request.
type FormRequest struct {
// Kind is one of: "render-empty", "create", "render-edit", "update".
// Kind is one of: "render-empty", "create", "render-edit", "update",
// or "create-via-ssr" (the special SSR create flow which materializes
// a new party folder + ssr.yaml).
Kind string
// SpecPath is the absolute filesystem path to the <name>.form.yaml.
SpecPath string
// DataPath is the absolute filesystem path to the data .yaml; empty for
// render-empty / create.
// render-empty / create / create-via-ssr.
DataPath string
// SubmitURL is the URL the form should POST back to (the server-injected
// "submit to my own URL" value).
SubmitURL string
// Project carries the project name for create-via-ssr requests. Empty
// for all other kinds.
Project string
}
// RecognizeFormRequest classifies r as a form-system request, or returns nil
@ -103,17 +108,38 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
if !strings.HasSuffix(urlPath, ".html") {
return nil
}
// SSR create: /<project>/ssr/form.html maps to the special create
// path that materializes a new party folder (mkdir archive/<name>/)
// AND writes archive/<name>/ssr.yaml. Recognized before the generic
// form.html branch so it doesn't get misrouted as an in-dir create.
if project, ok := zddc.IsSSRCreateURL(urlPath); ok {
kind := "render-empty"
if method == http.MethodPost {
kind = "create-via-ssr"
}
// SpecPath is the embedded default SSR form schema; the loader
// falls back to embedded bytes via IsDefaultSpecAbs. The path
// itself is the virtual <project>/ssr/form.yaml location.
specAbs := filepath.Join(fsRoot, project, "ssr", "form.yaml")
return &FormRequest{
Kind: kind,
SpecPath: specAbs,
SubmitURL: urlPath,
Project: project,
}
}
underlying := strings.TrimSuffix(urlPath, ".html")
// specEligible accepts a spec path that exists on disk OR matches
// the default-MDL virtual-fallback shape at archive/<party>/mdl/.
// Without this, the default-MDL row form would 404 on a fresh
// archive even though the table view renders.
// any of the default-spec virtual-fallback shapes (per-party
// mdl/rsk, per-party SSR schema, project-level virtual specs).
specEligible := func(specAbs string) bool {
if fileExists(specAbs) {
return true
}
if _, ok := IsDefaultMdlSpecAbs(fsRoot, specAbs); ok {
if _, ok := IsDefaultSpecAbs(fsRoot, specAbs); ok {
return true
}
return false
@ -154,7 +180,36 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
if strings.HasSuffix(underlying, ".yaml") {
// /<dir>/<id>.yaml.html — re-edit / update. Spec lives in the
// SAME directory as the row file (<dir>/form.yaml).
// SAME directory as the row file (<dir>/form.yaml) UNLESS the
// URL maps to one of the project-level virtual views, in which
// case the canonical SpecPath / DataPath are inside the per-
// party archive folder. ResolveVirtualView handles the rewrite;
// SubmitURL stays as the virtual URL so the form POSTs back to
// the same endpoint (which re-resolves to the same canonical
// paths on the second pass).
if vv := zddc.ResolveVirtualView(fsRoot, underlying); vv.Resolved && vv.Kind.IsRowKind() {
var specPath string
switch vv.Kind {
case zddc.VirtualViewSSRRow:
specPath = vv.SchemaAbs
case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow:
specPath = filepath.Join(vv.PartyArchive, vv.Slot, "form.yaml")
}
if !specEligible(specPath) {
return nil
}
kind := "render-edit"
if method == http.MethodPost {
kind = "update"
}
return &FormRequest{
Kind: kind,
SpecPath: specPath,
DataPath: vv.CanonicalAbs,
SubmitURL: urlPath,
}
}
dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/")))
dataAbs := filepath.Join(fsRoot, dataRel)
if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot {
@ -192,6 +247,8 @@ func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *ht
serveFormCreate(cfg, req, w, r)
case "update":
serveFormUpdate(cfg, req, w, r)
case "create-via-ssr":
serveFormCreateSSR(cfg, req, w, r)
default:
http.Error(w, "unknown form request kind", http.StatusInternalServerError)
}
@ -202,8 +259,6 @@ func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *ht
// in v0 — POST returns JSON 422 and the client patches errors into the live
// form via JS).
func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request, validationErrs []jsonschema.Error) {
email := EmailFromContext(r)
// ACL: read-rights at the directory holding the spec (and, for edits, at
// the directory holding the data file). Cascade chain is the same for
// every entity in the same directory — a single check covers both.
@ -215,7 +270,7 @@ func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter,
if err != nil {
slog.Warn("form: policy error", "path", gateDir, "err", err)
}
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), DeciderFromContext(r), chain, PrincipalFromContext(r), r.URL.Path); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -294,7 +349,7 @@ func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
if err != nil {
slog.Warn("form: policy error", "path", gateDir, "err", err)
}
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), DeciderFromContext(r), chain, PrincipalFromContext(r), r.URL.Path); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -377,7 +432,7 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
if err != nil {
slog.Warn("form: policy error", "path", req.DataPath, "err", err)
}
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
if allowed, _ := policy.AllowFromChainP(r.Context(), DeciderFromContext(r), chain, PrincipalFromContext(r), r.URL.Path); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -419,13 +474,13 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
func loadFormSpec(fsRoot, path string) (*FormSpec, error) {
data, err := os.ReadFile(path)
if err != nil {
// Default-MDL virtual fallback: when the operator hasn't placed
// an mdl.form.yaml under archive/<party>/, serve the embedded
// default. Mirrors the static-handler fallback for direct YAML
// fetches so the form recognizer and the loader agree on what
// "this spec exists" means.
// Default-spec virtual fallback: when no operator file exists at
// path, serve the embedded default if path matches one of the
// recognized virtual fallback shapes (per-party mdl/rsk, per-
// party SSR schema, project-level virtual specs). Mirrors the
// static-handler fallback for direct YAML fetches.
if os.IsNotExist(err) {
if bytes, ok := IsDefaultMdlSpecAbs(fsRoot, path); ok {
if bytes, ok := IsDefaultSpecAbs(fsRoot, path); ok {
data = bytes
} else {
return nil, err

View file

@ -81,6 +81,7 @@ func formTestSetup(t *testing.T, zddcFiles map[string]string) (config.Config, fu
req = httptest.NewRequest(method, target, nil)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
@ -228,7 +229,7 @@ func mustWrite(t *testing.T, path, body string) {
func TestRenderEmptyForm(t *testing.T) {
_, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*@example.com"]
permissions: {"*@example.com": rwcd}
`,
})
rec := do(http.MethodGet, "/Working/safety/form.html", "casey@example.com", "")
@ -252,7 +253,7 @@ func TestRenderEmptyForm(t *testing.T) {
func TestRenderEmptyForm_ACLDeny(t *testing.T) {
_, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["root@example.com"]
permissions: {"root@example.com": rwcd}
`,
})
rec := do(http.MethodGet, "/Working/safety/form.html", "stranger@example.com", "")
@ -264,7 +265,7 @@ func TestRenderEmptyForm_ACLDeny(t *testing.T) {
func TestCreateSubmission_Valid(t *testing.T) {
cfg, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*@example.com"]
permissions: {"*@example.com": rwcd}
`,
})
@ -304,7 +305,7 @@ func TestCreateSubmission_Valid(t *testing.T) {
func TestCreateSubmission_Invalid_Returns422(t *testing.T) {
_, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*@example.com"]
permissions: {"*@example.com": rwcd}
`,
})
@ -342,7 +343,7 @@ func TestCreateSubmission_Invalid_Returns422(t *testing.T) {
func TestCreateSubmission_ACLDeny(t *testing.T) {
_, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["root@example.com"]
permissions: {"root@example.com": rwcd}
`,
})
body := `{"date":"2026-05-01","location":"Site A"}`
@ -355,7 +356,7 @@ func TestCreateSubmission_ACLDeny(t *testing.T) {
func TestCreateSubmission_NoAuth_Returns401(t *testing.T) {
_, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*"]
permissions: {"*": rwcd}
`,
})
body := `{"date":"2026-05-01","location":"Site A"}`
@ -368,7 +369,7 @@ func TestCreateSubmission_NoAuth_Returns401(t *testing.T) {
func TestCreateSubmission_FilenameCollision(t *testing.T) {
cfg, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*@example.com"]
permissions: {"*@example.com": rwcd}
`,
})
body := `{"date":"2026-05-01","location":"Site A"}`
@ -402,7 +403,7 @@ func TestCreateSubmission_FilenameCollision(t *testing.T) {
func TestRenderEdit_LoadsSubmission(t *testing.T) {
cfg, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*@example.com"]
permissions: {"*@example.com": rwcd}
`,
})
@ -430,7 +431,7 @@ func TestRenderEdit_LoadsSubmission(t *testing.T) {
func TestUpdateSubmission_OverwritesFile(t *testing.T) {
cfg, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*@example.com"]
permissions: {"*@example.com": rwcd}
`,
})
@ -464,7 +465,7 @@ func TestUpdateSubmission_OverwritesFile(t *testing.T) {
func TestUpdateSubmission_NotFound(t *testing.T) {
_, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*@example.com"]
permissions: {"*@example.com": rwcd}
`,
})
body := `{"date":"2026-05-01","location":"Site A"}`

View file

@ -4,12 +4,15 @@ import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"log/slog"
)
@ -26,6 +29,23 @@ const EmailKey contextKey = "email"
// "swap internal evaluator for external OPA" plumbing change.
const DeciderKey contextKey = "policy-decider"
// ElevatedKey is the context key for the per-request elevation flag.
// Drives zddc.Principal{Elevated} for admin-authority checks. Set by
// ACLMiddleware according to the request's auth shape:
// - Bearer tokens are implicitly elevated (machine clients can't
// toggle a cookie; they're expected to act with the bearer's full
// authority).
// - Header-auth (browser) sessions elevate iff the request carries
// a `zddc-elevate=1` cookie. The cookie is set/cleared by the
// elevation toggle UI in the tool headers.
const ElevatedKey contextKey = "elevated"
// elevationCookieName is the cookie clients set to elevate their admin
// powers for header-auth (browser) sessions. Value "1" = elevated; any
// other value (or absent) = treat as non-admin even if the email is
// named in admin lists.
const elevationCookieName = "zddc-elevate"
// ACLMiddleware extracts the user email and stores it (along with the
// policy decider) in the request context. It does NOT enforce ACL
// itself — each handler performs its own ACL check via
@ -49,6 +69,7 @@ const DeciderKey contextKey = "policy-decider"
func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var email string
var elevated bool
if bearer := bearerToken(r); bearer != "" {
if tokens == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
@ -63,8 +84,20 @@ func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store
return
}
email = tok.Email
// Bearer-token callers (CLI tools, scripts, mirror clients)
// can't toggle a cookie — they're expected to operate with
// the bearer's full authority. Implicit elevation keeps the
// admin functions usable from the machine-client path.
elevated = true
} else {
email = r.Header.Get(cfg.EmailHeader)
// Browser sessions opt in to admin powers via the UI's
// elevation toggle, which sets a `zddc-elevate=1` cookie.
// Absent / any other value → treat as non-admin even when
// the email is named in admin lists.
if c, err := r.Cookie(elevationCookieName); err == nil && c.Value == "1" {
elevated = true
}
}
// DEBUG-level header dump for diagnosing proxy / SSO header
// passthrough. Off by default (LogLevel info); enable with
@ -79,6 +112,7 @@ func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store
"observed", email,
"headers", r.Header)
ctx := context.WithValue(r.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, elevated)
if decider != nil {
ctx = context.WithValue(ctx, DeciderKey, decider)
}
@ -116,6 +150,91 @@ func WithEmail(ctx context.Context, email string) context.Context {
return context.WithValue(ctx, EmailKey, email)
}
// ElevatedFromContext reports whether the request has opted into its
// admin powers. False for any request that wasn't tagged by
// ACLMiddleware (including tests that don't install it), so admin
// checks fail closed.
func ElevatedFromContext(r *http.Request) bool {
if v, ok := r.Context().Value(ElevatedKey).(bool); ok {
return v
}
return false
}
// WithElevation returns a context carrying the elevation flag under
// ElevatedKey. Test seam for the matching PrincipalFromContext lookup.
func WithElevation(ctx context.Context, elevated bool) context.Context {
return context.WithValue(ctx, ElevatedKey, elevated)
}
// activeAdminForRequest reports whether the elevated principal would
// trigger the decider's admin-bypass branch on the chain at the
// request's target path, AND which chain level conferred that
// authority. Returned level is 0-based (root=0) when authority is
// active, -1 otherwise.
//
// Best-effort: walks the closest existing ancestor (mirroring the
// file API's authorize logic) so a write targeting a not-yet-
// existing file still answers correctly. Returns -1 on anonymous
// or un-elevated requests without touching the filesystem. The
// cascade is mtime-cached upstream, so the per-request cost is one
// map lookup in the common case.
func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, email string) int {
if !elevated || email == "" || email == "anonymous" {
return -1
}
cleanURL := strings.TrimSuffix(r.URL.Path, "/")
if cleanURL == "" {
cleanURL = "/"
}
rel := strings.TrimPrefix(cleanURL, "/")
if rel == "" {
// Root request: chain is just the root .zddc.
chain, err := zddc.EffectivePolicy(cfg.Root, cfg.Root)
if err != nil {
return -1
}
return zddc.AdminLevelInChain(chain, email)
}
abs := filepath.Join(cfg.Root, filepath.FromSlash(rel))
if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root {
return -1
}
probe := abs
for {
if info, err := os.Stat(probe); err == nil && info.IsDir() {
break
}
if probe == cfg.Root {
break
}
parent := filepath.Dir(probe)
if parent == probe {
break
}
probe = parent
}
chain, err := zddc.EffectivePolicy(cfg.Root, probe)
if err != nil {
return -1
}
return zddc.AdminLevelInChain(chain, email)
}
// PrincipalFromContext bundles the request's authenticated email plus
// its elevation flag into a zddc.Principal — the value type the admin
// functions (IsAdmin, IsSubtreeAdmin) consume. One call per admin-check
// site replaces the previous ad-hoc email argument AND the previous
// "did I remember to gate this?" review burden: the type system
// enforces the gate by requiring a Principal value, which can only
// come from ACLMiddleware-tagged contexts.
func PrincipalFromContext(r *http.Request) zddc.Principal {
return zddc.Principal{
Email: EmailFromContext(r),
Elevated: ElevatedFromContext(r),
}
}
// DeciderFromContext extracts the policy decider from the request
// context. Returns the internal decider as a fallback if none was
// installed — this matches the "no OPA configured" semantics and
@ -181,7 +300,7 @@ func HSTSMiddleware(next http.Handler) http.Handler {
// so an operator gets a persisted audit trail on disk in addition to the
// stderr stream — useful when stderr is not journald-captured (e.g.
// container logging where the orchestrator drops stderr after restarts).
func AccessLogMiddleware(auditLogger *slog.Logger, next http.Handler) http.Handler {
func AccessLogMiddleware(cfg config.Config, auditLogger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture request start time
start := time.Now()
@ -201,15 +320,38 @@ func AccessLogMiddleware(auditLogger *slog.Logger, next http.Handler) http.Handl
// Calculate duration
durationMs := int(time.Since(start).Milliseconds())
// Get email from context
// Get email + elevation from context. `elevated` records the
// per-request opt-in (sudo-style); `active_admin` says whether
// the elevated user actually held admin authority on the path
// the request targeted — i.e., whether the single bypass
// branch in policy.InternalDecider.Allow would have fired
// here. Surfacing both lets forensics distinguish:
// elevated=false, active_admin=false: normal user
// elevated=true, active_admin=false: tried to elevate but no
// admin authority on this
// path (subtree-admin
// cooled by scope)
// elevated=true, active_admin=true: admin authority active,
// WORM/ACL bypassed
email := EmailFromContext(r)
if email == "" {
email = "anonymous"
}
elevated := ElevatedFromContext(r)
// adminLevel: 0-based chain index of the admins: entry that
// conferred authority on this request, or -1 if no admin
// authority applies. Lets forensics tell "root admin acted"
// (level 0) apart from "subtree admin acted" (level N) apart
// from "not admin" (-1). The active_admin bool is its
// presence/absence projected to a boolean.
adminLevel := activeAdminForRequest(cfg, r, elevated, email)
args := []any{
"ts", start.Format(time.RFC3339),
"email", email,
"elevated", elevated,
"active_admin", adminLevel >= 0,
"chain_admin_level", adminLevel,
"method", r.Method,
"path", requestedPath,
"status", wrapped.status,

View file

@ -2,13 +2,17 @@ package handler
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// TestAccessLogReadsEmailFromACLContext is a regression test for a bug where
@ -32,7 +36,7 @@ func TestAccessLogReadsEmailFromACLContext(t *testing.T) {
// Correct order: ACL is outer, AccessLog is inner. AccessLog reads
// email from the context ACL populated.
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(nil, noop))
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
@ -60,7 +64,7 @@ func TestAccessLogAnonymousWhenNoEmail(t *testing.T) {
w.WriteHeader(http.StatusOK)
})
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(nil, noop))
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
// Note: no X-Auth-Request-Email header set.
@ -90,7 +94,7 @@ func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) {
})
// Inverted order — the ORIGINAL buggy chain.
chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, nil, nil, noop))
chain := AccessLogMiddleware(cfg, nil, ACLMiddleware(cfg, nil, nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
@ -119,7 +123,7 @@ func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) {
})
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(auditLogger, noop))
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, auditLogger, noop))
req := httptest.NewRequest(http.MethodGet, "/some/path", nil)
req.Header.Set("X-Auth-Request-Email", "bob@example.com")
@ -136,3 +140,113 @@ func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) {
t.Errorf("audit log missing status code; got: %s", out)
}
}
// TestAccessLog_ChainAdminLevelAttribution pins the audit-log forensic
// invariant: every request record carries `chain_admin_level` matching
// the .zddc admins: level that conferred admin authority on this
// request, or -1 when no admin authority applies. Forensics use this to
// distinguish a root-admin write from a subtree-admin write from a
// non-admin write — three operationally distinct events that used to
// be conflated under a single `is_admin` boolean.
//
// Truth table the middleware must emit:
//
// (elevated, in admins at level N) → chain_admin_level=N, active_admin=true
// (elevated, in admins at no level) → chain_admin_level=-1, active_admin=false
// (not elevated, in admins) → chain_admin_level=-1, active_admin=false
// (anonymous, elevation flag ignored) → chain_admin_level=-1, active_admin=false
func TestAccessLog_ChainAdminLevelAttribution(t *testing.T) {
// Fixture: root admin at level 0; subtree admin at level 1 (Project-1).
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "Project-1"), 0o755); err != nil {
t.Fatalf("mkdir Project-1: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "Project-1", ".zddc"),
[]byte("admins:\n - alice@example.com\n"), 0o644); err != nil {
t.Fatalf("write subtree .zddc: %v", err)
}
zddc.InvalidateCache(root)
zddc.InvalidateCache(filepath.Join(root, "Project-1"))
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
type record struct {
Email string `json:"email"`
Elevated bool `json:"elevated"`
ActiveAdmin bool `json:"active_admin"`
ChainAdminLevel int `json:"chain_admin_level"`
Path string `json:"path"`
}
parse := func(t *testing.T, buf *bytes.Buffer) record {
t.Helper()
var rec record
if err := json.Unmarshal(buf.Bytes(), &rec); err != nil {
t.Fatalf("audit log not valid JSON: %v; raw=%s", err, buf.String())
}
return rec
}
cases := []struct {
name string
email string
elevate bool
path string
wantLevel int
wantActive bool
}{
{"root admin elevated probing root → level 0", "root@example.com", true, "/", 0, true},
{"root admin elevated probing project → level 0 (walks down chain)", "root@example.com", true, "/Project-1/", 0, true},
{"subtree admin elevated probing own subtree → level 1", "alice@example.com", true, "/Project-1/", 1, true},
{"subtree admin elevated probing root → -1 (out of scope)", "alice@example.com", true, "/", -1, false},
{"root admin un-elevated → -1 (no live authority)", "root@example.com", false, "/", -1, false},
{"non-admin elevated → -1 (elevation alone confers nothing)", "stranger@example.com", true, "/", -1, false},
{"anonymous → -1", "", false, "/", -1, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
auditLogger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, auditLogger, noop))
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
if tc.email != "" {
req.Header.Set("X-Auth-Request-Email", tc.email)
}
if tc.elevate {
req.AddCookie(&http.Cookie{Name: "zddc-elevate", Value: "1"})
}
chain.ServeHTTP(httptest.NewRecorder(), req)
rec := parse(t, &buf)
if rec.ChainAdminLevel != tc.wantLevel {
t.Errorf("chain_admin_level = %d, want %d", rec.ChainAdminLevel, tc.wantLevel)
}
if rec.ActiveAdmin != tc.wantActive {
t.Errorf("active_admin = %v, want %v", rec.ActiveAdmin, tc.wantActive)
}
// active_admin is the projection of chain_admin_level — these
// two fields must agree on every record. Asserted explicitly
// so a future refactor that drops the chain_admin_level field
// (or recomputes active_admin from a different source) trips
// this test before the forensic invariant rots.
if rec.ActiveAdmin != (rec.ChainAdminLevel >= 0) {
t.Errorf("active_admin must equal (chain_admin_level >= 0); got active=%v level=%d",
rec.ActiveAdmin, rec.ChainAdminLevel)
}
// Elevation flag must round-trip independently — distinguishes
// "tried to elevate, no authority" (elevated=true, active=false)
// from "didn't elevate" (elevated=false, active=false).
if rec.Elevated != tc.elevate {
t.Errorf("elevated = %v, want %v", rec.Elevated, tc.elevate)
}
})
}
}

View file

@ -0,0 +1,85 @@
package handler
import (
"errors"
"path/filepath"
"strings"
)
// URL ↔ filesystem path math used by several handler files. Pure
// string manipulation — no I/O, no policy decisions — so it lives
// in its own file rather than being attached to any one feature.
// resolvePath translates a URL `path=` query (relative to fsRoot, with
// '/' separator and leading '/') into an absolute filesystem path. It
// rejects path traversal and any segment beginning with '.' or '_' so
// reserved namespaces (e.g. .devshell) cannot be addressed through
// admin APIs. Returns the cleaned absolute path or an error suitable
// for a 404.
func resolvePath(fsRoot, urlPath string) (string, error) {
urlPath = strings.TrimSpace(urlPath)
if urlPath == "" {
urlPath = "/"
}
if !strings.HasPrefix(urlPath, "/") {
return "", errors.New("path must be absolute (start with /)")
}
cleanURL := filepath.ToSlash(filepath.Clean(urlPath))
// Reject reserved-prefix segments so callers cannot create
// .foo/.zddc or _bar/.zddc through admin APIs.
for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") {
if seg == "" {
continue
}
if strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") {
return "", errors.New("reserved-prefix path segment")
}
}
rel := strings.TrimPrefix(cleanURL, "/")
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
abs = filepath.Clean(abs)
// Path containment.
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
return "", errors.New("path escapes root")
}
return abs, nil
}
// urlPathOf produces the URL form of an absolute filesystem path under
// fsRoot. Returns "/" for fsRoot itself, otherwise "/<rel>".
func urlPathOf(fsRoot, abs string) string {
if abs == fsRoot {
return "/"
}
rel, err := filepath.Rel(fsRoot, abs)
if err != nil {
return "/"
}
return "/" + filepath.ToSlash(rel)
}
// chainDirs reproduces EffectivePolicy's directory walk so callers can
// label each policy-chain level with the directory it came from. Used
// by the virtual-.zddc body to annotate which ancestor contributed
// which rule.
func chainDirs(fsRoot, dirPath string) []string {
fsRoot = filepath.Clean(fsRoot)
dirPath = filepath.Clean(dirPath)
dirs := []string{fsRoot}
if dirPath == fsRoot {
return dirs
}
rel, err := filepath.Rel(fsRoot, dirPath)
if err != nil || rel == "." {
return dirs
}
current := fsRoot
for _, part := range strings.Split(rel, string(filepath.Separator)) {
current = filepath.Join(current, part)
dirs = append(dirs, current)
}
return dirs
}

View file

@ -0,0 +1,462 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"gopkg.in/yaml.v3"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// Plan Review — the doc-controller's "establish the canonical record"
// step. Right-click on archive/<party>/received/<tracking>/ in the
// browse app; the client POSTs X-ZDDC-Op: plan-review with the body
// below.
//
// Authorisation model — no ACL exception, only existing grants:
//
// - Create authority on received/<tracking>/. The doc_controller
// gets this from `worm: [document_controller]` on received/ in the
// cascade defaults; the same `c` (write-once-create) verb that
// lets them file canonical submittals lets them establish this
// .zddc once.
// - ActionAdmin on reviewing_root/.zddc + staging_root/.zddc. The
// invoker must already administer those subtrees per the cascade
// defaults.
//
// Operation:
//
// 1. Workflow folders converge first (idempotent — match by
// .zddc.received_path; mkdir if missing; rewrite workflow .zddc
// with received_path + ACL).
// 2. Write received/<tracking>/.zddc — but only if it doesn't exist.
// The .zddc schema is server-constrained to {planned_review_date,
// planned_response_date, created_by} — no ACL, admins, or other
// fields, so this write cannot escalate the invoker's authority.
// If the file already exists, the canonical record is sealed; the
// dates in the request are ignored and the workflow folders are
// converged on top.
//
// So Plan Review's first run establishes the canonical commitment;
// subsequent runs can only re-converge the workflow ACLs (e.g. swap
// review lead). The planned dates are write-once — to change them, an
// admin must edit received/<tracking>/.zddc directly via their admin
// authority (which under the cascade defaults is nobody beneath the
// root admin; deliberate).
const opPlanReview = "plan-review"
// planReviewRequest is the YAML body the browse client POSTs.
type planReviewRequest struct {
ReviewLead string `yaml:"review_lead"`
Approver string `yaml:"approver"`
PlanReviewCompleteDate string `yaml:"plan_review_complete_date"`
PlanResponseDate string `yaml:"plan_response_date"`
}
// planReviewResponse is the JSON returned to the client.
type planReviewResponse struct {
Tracking string `json:"tracking"`
Title string `json:"title"`
Reviewing planReviewFolderOK `json:"reviewing"`
Staging planReviewFolderOK `json:"staging"`
Received planReviewFolderOK `json:"received"`
}
type planReviewFolderOK struct {
Path string `json:"path"`
Created bool `json:"created"`
ZddcWritten bool `json:"zddc_written"`
}
// receivedURLPattern matches /<project>/archive/<party>/received/<tracking>/
// — Plan Review is only valid at that depth. Trailing slash required.
var receivedURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/received/([^/]+)/?$`)
func servePlanReview(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// 1. URL must be a received-tracking folder.
cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/"
m := receivedURLPattern.FindStringSubmatch(cleanURL)
if m == nil {
http.Error(w, "Bad Request — plan-review must POST to /<project>/archive/<party>/received/<tracking>/", http.StatusBadRequest)
return
}
project, party, tracking := m[1], m[2], m[3]
// 2. Body parse.
body, ok := readBodyCapped(cfg, w, r)
if !ok {
return
}
var req planReviewRequest
if err := yaml.Unmarshal(body, &req); err != nil {
http.Error(w, "Bad Request — could not parse YAML body: "+err.Error(), http.StatusBadRequest)
return
}
if req.ReviewLead == "" || req.Approver == "" ||
req.PlanReviewCompleteDate == "" || req.PlanResponseDate == "" {
http.Error(w, "Bad Request — body must include review_lead, approver, plan_review_complete_date, plan_response_date", http.StatusBadRequest)
return
}
resp, status, msg := executePlanReview(cfg, r, project, party, tracking, req)
if status != http.StatusOK {
auditFile(r, "plan-review", cleanURL, status, 0, nil)
http.Error(w, msg, status)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "fileapi:plan-review")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
auditFile(r, "plan-review", cleanURL, http.StatusOK, 0, nil)
}
// executePlanReview runs the Plan Review three-stage flow against an
// already-resolved received/<tracking>/ path. URL and body parsing
// happen in the caller. Returns the response struct on success;
// non-200 (status, message) on auth or execution failure. The caller
// is responsible for writing the HTTP response.
//
// Exposed so accept-transmittal can chain Plan Review in the same
// request without round-tripping through HTTP.
func executePlanReview(cfg config.Config, r *http.Request, project, party, tracking string, req planReviewRequest) (*planReviewResponse, int, string) {
receivedRel := filepath.ToSlash(filepath.Join("archive", party, "received", tracking))
receivedAbs := filepath.Join(cfg.Root, project, filepath.FromSlash(receivedRel))
cleanURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"
prCfg := zddc.OnPlanReviewAt(cfg.Root, receivedAbs)
if prCfg == nil || prCfg.ReviewingRoot == "" || prCfg.StagingRoot == "" {
return nil, http.StatusConflict, "Conflict — on_plan_review is not configured in the cascade for this subtree"
}
reviewingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.ReviewingRoot, "/")))
stagingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.StagingRoot, "/")))
// Pre-flight authorisation. No ACL exception — we use existing
// cascade grants:
// (a) ActionAdmin on reviewing_root and staging_root proves the
// invoker is subtree-admin of the workflow roots and can
// write the workflow .zddc files.
// (b) The invoker has `c` (write-once-create) authority on
// received/<tracking>/. For the doc_controller this comes
// from `worm: [document_controller]` on received/ in the
// cascade defaults — the same authority that lets them file
// canonical submittals lets them establish this .zddc once.
p := PrincipalFromContext(r)
email := EmailFromContext(r)
if email == "" {
return nil, http.StatusForbidden, "Forbidden — no authenticated principal"
}
// All three pre-flight checks go through the consolidated decider.
// AllowActionFromChainP routes ActionAdmin .zddc edits and the
// single admin-bypass branch for elevated admins. No manual
// IsAdmin / IsSubtreeAdmin branching here.
decider := DeciderFromContext(r)
for _, root := range []string{reviewingRoot, stagingRoot} {
chain, perr := zddc.EffectivePolicy(cfg.Root, root)
if perr != nil {
return nil, http.StatusInternalServerError, "Internal Server Error — cascade lookup: " + perr.Error()
}
rel, _ := filepath.Rel(cfg.Root, root)
rootURL := "/" + filepath.ToSlash(rel) + "/.zddc"
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, rootURL, policy.ActionAdmin)
if !allowed {
return nil, http.StatusForbidden, fmt.Sprintf("Forbidden — %s lacks subtree-admin authority for %s",
email, strings.TrimPrefix(root, cfg.Root+string(filepath.Separator)))
}
}
// Verify `c` (create) authority on received/<tracking>/. Elevated
// admins short-circuit inside the decider; non-admin doc_controllers
// come through the WORM-list grant. One code path either way.
{
chain, perr := zddc.EffectivePolicy(cfg.Root, receivedAbs)
if perr != nil {
return nil, http.StatusInternalServerError, "Internal Server Error — cascade lookup: " + perr.Error()
}
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, cleanURL, policy.ActionCreate)
if !allowed {
return nil, http.StatusForbidden, fmt.Sprintf("Forbidden — %s lacks create authority on %s (filing this submittal requires the doc_controller WORM grant)",
email, strings.TrimPrefix(receivedAbs, cfg.Root+string(filepath.Separator)))
}
}
// Derive a title from received/<tracking>/'s contents — first
// ZDDC-parseable filename's title field wins. Fallback to the
// tracking number itself so the folder name always has a tail.
title := deriveTitleFromReceived(receivedAbs)
if title == "" {
title = tracking
}
// Materialise roots + received/<tracking>/ ancestors (the received
// folder itself was created when the doc controller moved the
// submittal in; defensive ensure here for tests).
for _, root := range []string{reviewingRoot, stagingRoot, receivedAbs} {
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, http.StatusInternalServerError, "Internal Server Error — ensure dirs: " + err.Error()
}
}
// received/<tracking>/.zddc is WRITE-ONCE — the canonical commitment.
// First-run creates it under the invoker's WORM-`c` authority
// (verified above); subsequent runs leave it alone and the request's
// date fields are ignored. The schema is server-constrained: only
// planned_review_date + planned_response_date + created_by are written.
// No ACL, admins, or other content — so this write cannot escalate
// the invoker's authority.
receivedResult, err := establishReceivedPlanDates(receivedAbs, req.PlanReviewCompleteDate, req.PlanResponseDate, email, cfg.Root)
if err != nil {
return nil, http.StatusInternalServerError, "Internal Server Error — received .zddc: " + err.Error()
}
// Converge the workflow folders.
reviewingResult, err := convergeWorkflowFolder(workflowConverge{
fsRoot: cfg.Root,
root: reviewingRoot,
forecast: req.PlanReviewCompleteDate,
tracking: tracking,
title: title,
receivedRel: receivedRel,
acl: map[string]string{req.ReviewLead: "rwcda"},
creatorEmail: email,
})
if err != nil {
return nil, http.StatusInternalServerError, "Internal Server Error — reviewing convergence: " + err.Error()
}
stagingResult, err := convergeWorkflowFolder(workflowConverge{
fsRoot: cfg.Root,
root: stagingRoot,
forecast: req.PlanResponseDate,
tracking: tracking,
title: title,
receivedRel: receivedRel,
acl: map[string]string{req.Approver: "rwcda"},
creatorEmail: email,
})
if err != nil {
return nil, http.StatusInternalServerError, "Internal Server Error — staging convergence: " + err.Error()
}
return &planReviewResponse{
Tracking: tracking,
Title: title,
Reviewing: planReviewFolderOK{
Path: "/" + filepath.ToSlash(reviewingResult.relPath) + "/",
Created: reviewingResult.created,
ZddcWritten: reviewingResult.zddcWritten,
},
Staging: planReviewFolderOK{
Path: "/" + filepath.ToSlash(stagingResult.relPath) + "/",
Created: stagingResult.created,
ZddcWritten: stagingResult.zddcWritten,
},
Received: planReviewFolderOK{
Path: "/" + filepath.ToSlash(receivedResult.relPath) + "/",
Created: receivedResult.created,
ZddcWritten: receivedResult.zddcWritten,
},
}, http.StatusOK, ""
}
// establishReceivedPlanDates writes received/<tracking>/.zddc with the
// committed planned dates iff the file doesn't yet exist. If it does,
// the canonical record is already sealed and the call is a no-op
// (zddcWritten=false in the result); the request's date fields are
// silently ignored on subsequent runs. The schema is server-constrained
// to just the two date fields + created_by — no ACL or admin grants.
func establishReceivedPlanDates(receivedAbs, planReview, planResponse, creatorEmail, fsRoot string) (workflowResult, error) {
var res workflowResult
res.absPath = receivedAbs
if rel, err := filepath.Rel(fsRoot, receivedAbs); err == nil {
res.relPath = filepath.ToSlash(rel)
} else {
res.relPath = receivedAbs
}
zddcPath := filepath.Join(receivedAbs, ".zddc")
if _, err := os.Stat(zddcPath); err == nil {
// Sealed — leave alone. zddcWritten stays false.
return res, nil
} else if !errors.Is(err, os.ErrNotExist) {
return res, err
}
zf := zddc.ZddcFile{
PlannedReviewDate: planReview,
PlannedResponseDate: planResponse,
CreatedBy: creatorEmail,
}
if err := zddc.WriteFile(receivedAbs, zf); err != nil {
return res, err
}
res.zddcWritten = true
res.created = true // first-time establishment
return res, nil
}
// deriveTitleFromReceived scans received/<tracking>/ for ZDDC-parseable
// filenames and returns the first one's title field. Empty if no
// parseable file is found.
func deriveTitleFromReceived(receivedAbs string) string {
entries, err := os.ReadDir(receivedAbs)
if err != nil {
return ""
}
// Sort for deterministic title selection (first alphabetical wins).
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
names = append(names, e.Name())
}
sort.Strings(names)
for _, name := range names {
parsed := zddc.ParseFilename(name)
if parsed.Valid && parsed.Title != "" {
return parsed.Title
}
}
return ""
}
// workflowConverge captures the parameters for converging a single
// reviewing/ or staging/ workflow folder.
type workflowConverge struct {
fsRoot string // master root (cfg.Root) — used to compute response paths
root string // absolute path of reviewing_root or staging_root
forecast string // initial forecast date for the folder name (YYYY-MM-DD)
tracking string // tracking number
title string // derived title
receivedRel string // relative path to canonical submittal, e.g. archive/Acme/received/Acme-0042
acl map[string]string // per-folder ACL grants (principal → verb-set)
creatorEmail string // creator/audit email
}
// workflowResult is the post-convergence summary for one folder.
type workflowResult struct {
relPath string // server-relative path (no leading slash, no trailing slash)
absPath string
created bool // true iff this convergence run mkdir'd the folder
zddcWritten bool // true iff a .zddc was written (always true on success)
}
// convergeWorkflowFolder converges one of the workflow folders (reviewing
// or staging) toward the desired state. Idempotent on re-run.
func convergeWorkflowFolder(c workflowConverge) (workflowResult, error) {
var res workflowResult
// Search the root for an existing folder whose .zddc.received_path
// matches. If found, use it — the user controls the folder name via
// direct rename, so we don't fight their date.
existing, err := findWorkflowFolderByReceivedPath(c.root, c.receivedRel)
if err != nil {
return res, err
}
target := existing
if target == "" {
// No match — mkdir at <root>/<forecast>_<tracking> (TBD) - <title>/.
// Append _2, _3 to disambiguate exact-name collisions with a
// folder belonging to a DIFFERENT submittal.
baseName := sanitiseFolderName(fmt.Sprintf("%s_%s (TBD) - %s", c.forecast, c.tracking, c.title))
candidate := filepath.Join(c.root, baseName)
for n := 2; n <= 100; n++ {
if _, statErr := os.Stat(candidate); errors.Is(statErr, os.ErrNotExist) {
break
} else if statErr != nil {
return res, statErr
}
candidate = filepath.Join(c.root, fmt.Sprintf("%s_%d", baseName, n))
if n == 100 {
return res, fmt.Errorf("convergence: exhausted suffix attempts for %s", baseName)
}
}
if err := os.MkdirAll(candidate, 0o755); err != nil {
return res, fmt.Errorf("mkdir workflow folder: %w", err)
}
target = candidate
res.created = true
}
// Write .zddc with desired content. Overwrites if present. Workflow
// .zddc carries received_path + acl ONLY — no planned dates (those
// live in the canonical received/.zddc, which the sub-admins
// cannot modify).
zf := zddc.ZddcFile{
ReceivedPath: c.receivedRel,
CreatedBy: c.creatorEmail,
}
if len(c.acl) > 0 {
zf.ACL = zddc.ACLRules{Permissions: c.acl}
}
if err := zddc.WriteFile(target, zf); err != nil {
return res, fmt.Errorf("write workflow .zddc: %w", err)
}
res.zddcWritten = true
res.absPath = target
if rel, err := filepath.Rel(c.fsRoot, target); err == nil {
res.relPath = filepath.ToSlash(rel)
} else {
res.relPath = target
}
return res, nil
}
// findWorkflowFolderByReceivedPath scans root for direct children
// whose .zddc has received_path matching the given relative path.
// Returns the matching absolute path, or "" if none.
func findWorkflowFolderByReceivedPath(root, receivedRel string) (string, error) {
entries, err := os.ReadDir(root)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
return "", err
}
want := filepath.ToSlash(filepath.Clean(receivedRel))
for _, e := range entries {
if !e.IsDir() {
continue
}
zddcPath := filepath.Join(root, e.Name(), ".zddc")
zf, perr := zddc.ParseFile(zddcPath)
if perr != nil {
slog.Warn("plan-review: parse workflow .zddc", "path", zddcPath, "err", perr)
continue
}
if zf.ReceivedPath == "" {
continue
}
got := filepath.ToSlash(filepath.Clean(zf.ReceivedPath))
if got == want {
return filepath.Join(root, e.Name()), nil
}
}
return "", nil
}
// sanitiseFolderName replaces filesystem-troublesome characters in a
// title with safe substitutes. Conservative — keeps the ZDDC folder
// grammar (the parens and the " - " separator) intact while taming
// arbitrary user input in the title segment.
func sanitiseFolderName(name string) string {
repl := strings.NewReplacer(
"/", "-",
"\\", "-",
":", "-",
"\x00", "",
)
return strings.TrimSpace(repl.Replace(name))
}

View file

@ -0,0 +1,321 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// planReviewSetup writes a tree shaped like a real ZDDC project with
// `archive/Acme/received/Acme-0042/` populated and an admin grant for
// alice@example.com. Returns the cfg, a do() helper that POSTs Plan
// Review requests, and the root path.
func planReviewSetup(t *testing.T) (config.Config, func(target, email string, body []byte) *httptest.ResponseRecorder, string) {
t.Helper()
root := t.TempDir()
// Root .zddc grants alice subtree-admin everywhere AND sets the
// document_controller role so the cascade's reviewing/+staging/
// admin grants resolve to her. The role membership also confers
// `c` authority on received/ via the WORM list in the defaults,
// which Plan Review's pre-flight requires.
mustWriteHelper(t, filepath.Join(root, ".zddc"),
"admins:\n - alice@example.com\n"+
"roles:\n document_controller:\n members: [alice@example.com]\n")
for _, d := range []string{"Project-1/archive/Acme/received/Acme-0042"} {
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", d, err)
}
}
// Seed a ZDDC-parseable file so the title derives correctly.
mustWriteHelper(t, filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Foundation.pdf"),
"%PDF-")
zddc.InvalidateCache(root)
cfg := config.Config{
Root: root,
EmailHeader: "X-Auth-Request-Email",
MaxWriteBytes: 64 * 1024,
}
do := func(target, email string, body []byte) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
req.Header.Set(headerOp, opPlanReview)
req.Header.Set("Content-Type", "application/yaml")
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
return rec
}
return cfg, do, root
}
func mustWriteHelper(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir parent of %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func planReviewBody() string {
return strings.Join([]string{
"review_lead: bob@vendor.com",
"approver: carol@example.com",
"plan_review_complete_date: 2026-05-30",
"plan_response_date: 2026-06-15",
}, "\n") + "\n"
}
// TestPlanReview_FreshConvergence runs Plan Review against a tree with
// no existing workflow folders. Expects both reviewing/ and staging/
// to be created, each with a .zddc declaring received_path +
// planned_date, and the response to confirm both were created.
func TestPlanReview_FreshConvergence(t *testing.T) {
cfg, do, root := planReviewSetup(t)
rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
[]byte(planReviewBody()))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp planReviewResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v; body=%s", err, rec.Body.String())
}
if resp.Tracking != "Acme-0042" {
t.Errorf("Tracking=%q, want Acme-0042", resp.Tracking)
}
if !resp.Reviewing.Created || !resp.Reviewing.ZddcWritten {
t.Errorf("Reviewing not fully converged: %+v", resp.Reviewing)
}
if !resp.Staging.Created || !resp.Staging.ZddcWritten {
t.Errorf("Staging not fully converged: %+v", resp.Staging)
}
// Workflow folders: should carry received_path + ACL only.
for _, side := range []struct {
path string
wantDate string
actor string
}{
{resp.Reviewing.Path, "2026-05-30", "bob@vendor.com"},
{resp.Staging.Path, "2026-06-15", "carol@example.com"},
} {
abs := filepath.Join(root, filepath.FromSlash(strings.Trim(side.path, "/")))
base := filepath.Base(abs)
if !strings.HasPrefix(base, side.wantDate) {
t.Errorf("folder %q does not start with date %q", base, side.wantDate)
}
zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc"))
if err != nil {
t.Fatalf("parse %s/.zddc: %v", abs, err)
}
if zf.ReceivedPath != "archive/Acme/received/Acme-0042" {
t.Errorf("%s: received_path=%q", abs, zf.ReceivedPath)
}
// Workflow .zddc must NOT carry planned dates — those live in
// the canonical received/.zddc and are sealed.
if zf.PlannedReviewDate != "" || zf.PlannedResponseDate != "" {
t.Errorf("%s: workflow .zddc must not carry planned dates", abs)
}
if v, ok := zf.ACL.Permissions[side.actor]; !ok || v != "rwcda" {
t.Errorf("%s: ACL[%s]=%q, want rwcda", abs, side.actor, v)
}
}
// Canonical received/.zddc: planned dates are sealed here.
zfRecv, err := zddc.ParseFile(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/.zddc"))
if err != nil {
t.Fatalf("parse received .zddc: %v", err)
}
if zfRecv.PlannedReviewDate != "2026-05-30" {
t.Errorf("received planned_review_date=%q", zfRecv.PlannedReviewDate)
}
if zfRecv.PlannedResponseDate != "2026-06-15" {
t.Errorf("received planned_response_date=%q", zfRecv.PlannedResponseDate)
}
// Constrained schema: no ACL, no admins, no roles, no received_path.
if len(zfRecv.ACL.Permissions) != 0 || len(zfRecv.Admins) != 0 ||
len(zfRecv.Roles) != 0 || zfRecv.ReceivedPath != "" {
t.Errorf("received .zddc has unexpected content: acl=%v admins=%v roles=%v rp=%q",
zfRecv.ACL.Permissions, zfRecv.Admins, zfRecv.Roles, zfRecv.ReceivedPath)
}
if resp.Title != "Foundation" {
t.Errorf("Title=%q, want Foundation (from received file)", resp.Title)
}
_ = cfg
}
// TestPlanReview_Idempotent runs Plan Review twice with the same body;
// the second run is a no-op (created=false everywhere) and folder/.zddc
// state is unchanged.
func TestPlanReview_Idempotent(t *testing.T) {
_, do, root := planReviewSetup(t)
first := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
[]byte(planReviewBody()))
if first.Code != http.StatusOK {
t.Fatalf("first status=%d; body=%s", first.Code, first.Body.String())
}
var firstResp planReviewResponse
if err := json.Unmarshal(first.Body.Bytes(), &firstResp); err != nil {
t.Fatalf("decode first: %v", err)
}
second := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
[]byte(planReviewBody()))
if second.Code != http.StatusOK {
t.Fatalf("second status=%d; body=%s", second.Code, second.Body.String())
}
var secondResp planReviewResponse
if err := json.Unmarshal(second.Body.Bytes(), &secondResp); err != nil {
t.Fatalf("decode second: %v", err)
}
if secondResp.Reviewing.Created || secondResp.Staging.Created {
t.Errorf("second run created=true: %+v", secondResp)
}
if firstResp.Reviewing.Path != secondResp.Reviewing.Path {
t.Errorf("reviewing path drifted: %q vs %q",
firstResp.Reviewing.Path, secondResp.Reviewing.Path)
}
if firstResp.Staging.Path != secondResp.Staging.Path {
t.Errorf("staging path drifted: %q vs %q",
firstResp.Staging.Path, secondResp.Staging.Path)
}
// Confirm no duplicate folders snuck in.
reviewingRoot := filepath.Join(root, "Project-1", "reviewing")
entries, err := os.ReadDir(reviewingRoot)
if err != nil {
t.Fatalf("read %s: %v", reviewingRoot, err)
}
if len(entries) != 1 {
t.Errorf("reviewing/ has %d entries, want 1", len(entries))
}
}
// TestPlanReview_ReceivedZddcIsWriteOnce — re-running Plan Review with
// different planned dates leaves received/.zddc alone (sealed at first
// run). Workflow folder ACLs can still be re-converged on subsequent
// runs.
func TestPlanReview_ReceivedZddcIsWriteOnce(t *testing.T) {
_, do, root := planReviewSetup(t)
if rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
[]byte(planReviewBody())); rec.Code != http.StatusOK {
t.Fatalf("first POST status=%d; body=%s", rec.Code, rec.Body.String())
}
// Second run with a different review_lead AND a different planned
// date. The workflow .zddc should reflect the new actor, but the
// canonical received/.zddc must keep its original dates.
updated := strings.Join([]string{
"review_lead: dave@vendor.com",
"approver: carol@example.com",
"plan_review_complete_date: 2099-01-01", // attempted but should be ignored
"plan_response_date: 2099-01-15",
}, "\n") + "\n"
if rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
[]byte(updated)); rec.Code != http.StatusOK {
t.Fatalf("second POST status=%d; body=%s", rec.Code, rec.Body.String())
}
// received/.zddc unchanged.
zfRecv, err := zddc.ParseFile(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/.zddc"))
if err != nil {
t.Fatalf("parse received: %v", err)
}
if zfRecv.PlannedReviewDate != "2026-05-30" || zfRecv.PlannedResponseDate != "2026-06-15" {
t.Errorf("received dates drifted: review=%q response=%q",
zfRecv.PlannedReviewDate, zfRecv.PlannedResponseDate)
}
// reviewing/.zddc reflects the new review_lead.
reviewingRoot := filepath.Join(root, "Project-1", "reviewing")
entries, err := os.ReadDir(reviewingRoot)
if err != nil {
t.Fatalf("read %s: %v", reviewingRoot, err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 reviewing folder, got %d", len(entries))
}
zf, err := zddc.ParseFile(filepath.Join(reviewingRoot, entries[0].Name(), ".zddc"))
if err != nil {
t.Fatalf("parse: %v", err)
}
if _, ok := zf.ACL.Permissions["dave@vendor.com"]; !ok {
t.Errorf("reviewing ACL did not switch to dave: %v", zf.ACL.Permissions)
}
}
// TestPlanReview_Forbidden — a user without admin authority on the
// workflow roots gets 403 and no folders are created.
func TestPlanReview_Forbidden(t *testing.T) {
_, do, root := planReviewSetup(t)
rec := do("/Project-1/archive/Acme/received/Acme-0042/", "stranger@vendor.com",
[]byte(planReviewBody()))
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d, want 403; body=%s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "Project-1", "reviewing")); err == nil {
// reviewing/ should not have been materialised. The mkdir
// happens AFTER the ACL check in the handler, so refusal
// guarantees no state change.
entries, _ := os.ReadDir(filepath.Join(root, "Project-1", "reviewing"))
if len(entries) > 0 {
t.Errorf("reviewing/ created despite 403: %d entries", len(entries))
}
}
}
// TestCommentResolvedName — counter scope is per-target, plain target
// gets +C1, subsequent targets get sequential +C2/+C3.
func TestCommentResolvedName(t *testing.T) {
root := t.TempDir()
resolved, err := zddc.CommentResolvedName(root, "Acme-0042_A (RFI) - Foundation.pdf")
if err != nil {
t.Fatalf("first: %v", err)
}
if resolved != "Acme-0042_A+C1 (RFI) - Foundation.pdf" {
t.Errorf("first=%q, want +C1", resolved)
}
// Seed a +C1 file; next should be +C2.
if err := os.WriteFile(filepath.Join(root, resolved), []byte("x"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
resolved2, err := zddc.CommentResolvedName(root, "Acme-0042_A (RFI) - Foundation.pdf")
if err != nil {
t.Fatalf("second: %v", err)
}
if resolved2 != "Acme-0042_A+C2 (RFI) - Foundation.pdf" {
t.Errorf("second=%q, want +C2", resolved2)
}
// Different target → independent counter at +C1.
resolvedB, err := zddc.CommentResolvedName(root, "Acme-0042_B (RFI) - Foundation-Spec.pdf")
if err != nil {
t.Fatalf("B: %v", err)
}
if resolvedB != "Acme-0042_B+C1 (RFI) - Foundation-Spec.pdf" {
t.Errorf("B=%q, want +C1", resolvedB)
}
}

View file

@ -0,0 +1,56 @@
package handler
import (
"net/http"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// Custom CSS pipeline. Lets an operator drop `.profile.css` at the
// deployment root and have it picked up automatically as styling for
// the profile page.
const profileCustomCSSName = ".profile.css"
// hasCustomProfileCSS reports whether <fsRoot>/.profile.css exists.
// The profile template uses this to decide whether to inject the
// <link> tag.
func hasCustomProfileCSS(fsRoot string) bool {
_, err := os.Stat(filepath.Join(fsRoot, profileCustomCSSName))
return err == nil
}
// profileAssetsPathPrefix is the URL prefix for admin static assets.
// The only consumer is the profile page, which emits a <link> to
// /custom.css when an operator has placed one at root.
const profileAssetsPathPrefix = ProfilePathPrefix + "/assets"
// serveProfileAssets handles GET /.profile/assets/<file>. V1 only
// ships `custom.css` (passthrough of <root>/.profile.css when present);
// other paths return 404 so we don't accidentally expose arbitrary
// files. The caller (profilehandler.go) has already gated on admin
// scope.
func serveProfileAssets(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
rest := strings.TrimPrefix(r.URL.Path, profileAssetsPathPrefix+"/")
switch rest {
case "custom.css":
path := filepath.Join(cfg.Root, profileCustomCSSName)
if fi, err := os.Stat(path); err != nil || fi.IsDir() {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, path)
default:
http.NotFound(w, r)
}
}

View file

@ -34,51 +34,57 @@ func ServeProfile(cfg config.Config, ring *LogRing, idx *archive.Index, w http.R
sub = "/"
}
// Delegated to ServeZddc; that handler has its own hasAnyAdminScope gate.
if sub == "/zddc" || strings.HasPrefix(sub, "/zddc/") {
ServeZddc(cfg, w, r)
// /assets/ serves the profile page's custom.css when an operator
// has placed one at root.
if strings.HasPrefix(sub, "/assets/") {
serveProfileAssets(cfg, w, r)
return
}
email := EmailFromContext(r)
// adminOnly wraps an admin-gated sub-handler. Routes that need root-
// admin authority (sudo-style, elevation-gated) deny with 404 (not
// 403) so a non-admin probing the namespace can't enumerate which
// admin-only resources exist. Single helper instead of five copy-
// pasted gates.
adminOnly := func(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !zddc.IsAdmin(cfg.Root, PrincipalFromContext(r)) {
http.NotFound(w, r)
return
}
fn(w, r)
}
}
switch sub {
case "/", "":
serveProfilePage(cfg, w, r)
case "/access":
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, email))
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, PrincipalFromContext(r)))
case "/projects":
serveProfileProjectsCreate(cfg, w, r)
case "/whoami":
if !zddc.IsAdmin(cfg.Root, email) {
http.NotFound(w, r)
return
}
serveProfileWhoami(cfg, email, w, r)
adminOnly(func(w http.ResponseWriter, r *http.Request) {
serveProfileWhoami(cfg, email, w, r)
})(w, r)
case "/config":
if !zddc.IsAdmin(cfg.Root, email) {
http.NotFound(w, r)
return
}
serveProfileConfig(cfg, w, r)
adminOnly(func(w http.ResponseWriter, r *http.Request) {
serveProfileConfig(cfg, w, r)
})(w, r)
case "/logs":
if !zddc.IsAdmin(cfg.Root, email) {
http.NotFound(w, r)
return
}
serveProfileLogs(ring, w, r)
adminOnly(func(w http.ResponseWriter, r *http.Request) {
serveProfileLogs(ring, w, r)
})(w, r)
case "/effective-policy":
if !zddc.IsAdmin(cfg.Root, email) {
http.NotFound(w, r)
return
}
serveProfileEffectivePolicy(cfg, w, r)
adminOnly(func(w http.ResponseWriter, r *http.Request) {
serveProfileEffectivePolicy(cfg, w, r)
})(w, r)
case "/reindex":
if !zddc.IsAdmin(cfg.Root, email) {
http.NotFound(w, r)
return
}
serveProfileReindex(cfg, idx, email, w, r)
adminOnly(func(w http.ResponseWriter, r *http.Request) {
serveProfileReindex(cfg, idx, email, w, r)
})(w, r)
default:
http.NotFound(w, r)
}
@ -110,51 +116,82 @@ func serveProfileReindex(cfg config.Config, idx *archive.Index, email string, w
})
}
// treeEntry is one row in the AccessView's AdminSubtrees list — every
// directory containing a .zddc that the caller administers. The profile
// page renders them inline; the create-project form's parent-selector
// seeds from the same list.
type treeEntry struct {
Path string `json:"path"`
Title string `json:"title,omitempty"`
}
// AccessView is the data the profile page lazy-loads from /.profile/access
// after first paint. The HTML shell renders only Email/EmailHeader/
// IsSuperAdmin (all cheap); Projects + AdminSubtrees + HasAnyAdminScope come
// in via JS. EditableParentChoices is what the create-project form's
// parent-selector renders — derived from AdminSubtrees on the client.
// in via JS. AdminSubtrees doubles as the create-project parent-selector
// source — every entry is editable, since subtree admins own their own
// .zddc.
//
// IsSuperAdmin and HasAnyAdminScope reflect EFFECTIVE authority — gated
// by elevation. CanElevate is the independent "do you have any admin
// grant ANYWHERE in the tree, regardless of elevation?" signal that the
// header elevation toggle reads to decide whether to show itself.
type AccessView struct {
Email string `json:"email"`
EmailHeader string `json:"email_header"`
IsSuperAdmin bool `json:"is_super_admin"`
HasAnyAdminScope bool `json:"has_any_admin_scope"`
Projects []ProjectInfo `json:"projects"`
AdminSubtrees []treeEntry `json:"admin_subtrees"`
EditableParentChoices []treeEntry `json:"editable_parent_choices"`
Email string `json:"email"`
EmailHeader string `json:"email_header"`
IsSuperAdmin bool `json:"is_super_admin"`
HasAnyAdminScope bool `json:"has_any_admin_scope"`
CanElevate bool `json:"can_elevate"`
// CanCreateProject is true when the caller is authorized to mkdir a
// new top-level project — either via the root .zddc granting `c` to
// their email/role, or via super-admin authority (elevated). Drives
// the visibility of the profile page's "+ New project" form so the
// UI doesn't dangle an affordance the server would 404.
CanCreateProject bool `json:"can_create_project"`
Projects []ProjectInfo `json:"projects"`
AdminSubtrees []treeEntry `json:"admin_subtrees"`
}
// enumerateAccess builds an AccessView for the given caller. Used by the
// JSON endpoint at /.profile/access; the HTML page no longer calls this on
// the request hot path — it ships a shell first and the client fetches the
// view after first paint.
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, email string) AccessView {
// view after first paint. The principal carries elevation: an un-elevated
// admin reports IsSuperAdmin=false here, so the UI naturally renders the
// non-elevated view (no admin scaffolds shown) until the user opts in.
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal) AccessView {
view := AccessView{
Email: email,
Email: p.Email,
EmailHeader: cfg.EmailHeader,
IsSuperAdmin: zddc.IsAdmin(cfg.Root, email),
IsSuperAdmin: zddc.IsAdmin(cfg.Root, p),
}
view.Projects, _ = EnumerateProjects(ctx, decider, cfg, email)
view.AdminSubtrees = enumerateAdminSubtrees(cfg, email)
view.Projects, _ = EnumerateProjects(ctx, decider, cfg, p)
view.AdminSubtrees = enumerateAdminSubtrees(cfg, p)
view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
for _, t := range view.AdminSubtrees {
if t.CanEdit {
view.EditableParentChoices = append(view.EditableParentChoices, t)
}
// CanElevate is the elevation-INDEPENDENT discovery flag: "does
// this user have admin authority that they could opt into?"
// Drives the header elevation toggle's visibility — an un-
// elevated admin still needs to see the toggle they'd flip.
view.CanElevate = zddc.HasAnyAdminGrant(cfg.Root, p.Email)
// CanCreateProject mirrors the gate in serveProfileProjectsCreate —
// same decider call, same authority, no daylight between the UI
// affordance and the endpoint.
if rootChain, perr := zddc.EffectivePolicy(cfg.Root, cfg.Root); perr == nil {
allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate)
view.CanCreateProject = allowed
}
return view
}
// enumerateAdminSubtrees lists every directory containing a .zddc that the
// caller can see as an admin (super-admin or subtree-admin). Each entry
// carries can_edit so the page can label read-only entries (the file that
// grants the user's own authority).
func enumerateAdminSubtrees(cfg config.Config, email string) []treeEntry {
// caller can see as an admin (super-admin or subtree-admin). Every entry
// is editable — subtree admins own their own .zddc. Returns empty for an
// un-elevated principal — the elevation flag short-circuits each admin
// check below.
func enumerateAdminSubtrees(cfg config.Config, p zddc.Principal) []treeEntry {
dirs, _ := zddc.ScanZddcFiles(cfg.Root)
out := make([]treeEntry, 0, len(dirs))
for _, d := range dirs {
if !zddc.IsSubtreeAdmin(cfg.Root, d, email) && !zddc.IsAdmin(cfg.Root, email) {
if !zddc.IsSubtreeAdmin(cfg.Root, d, p) && !zddc.IsAdmin(cfg.Root, p) {
continue
}
var title string
@ -162,9 +199,8 @@ func enumerateAdminSubtrees(cfg config.Config, email string) []treeEntry {
title = zf.Title
}
out = append(out, treeEntry{
Path: urlPathOf(cfg.Root, d),
CanEdit: zddc.CanEditZddc(cfg.Root, d, email),
Title: title,
Path: urlPathOf(cfg.Root, d),
Title: title,
})
}
return out
@ -228,7 +264,6 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques
IndexPath string `json:"index_path"`
EmailHeader string `json:"email_header"`
CORSOrigins []string `json:"cors_origins"`
CascadeMode string `json:"cascade_mode"`
}
writeJSON(w, response{
Root: cfg.Root,
@ -240,7 +275,6 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques
IndexPath: cfg.IndexPath,
EmailHeader: cfg.EmailHeader,
CORSOrigins: cfg.CORSOrigins,
CascadeMode: cfg.CascadeMode,
})
}
@ -316,9 +350,9 @@ func levelRank(s string) int {
// "chain": {
// "has_any_file": true,
// "levels": [
// {"path": "/", "exists": true, "acl": {"allow": [...]}, "admins": [...]},
// {"path": "/", "exists": true, "acl": {"permissions": {...}}, "admins": [...]},
// {"path": "/Project-X/", "exists": false},
// {"path": "/Project-X/sub/", "exists": true, "acl": {"allow": [...]}}
// {"path": "/Project-X/sub/", "exists": true, "acl": {"permissions": {...}}}
// ]
// }
// }
@ -384,17 +418,15 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
// don't have per-level existence, but ZddcFile.Admins/ACL being
// non-empty is a reasonable proxy).
out := struct {
Path string `json:"path"`
Email string `json:"email"`
Decision bool `json:"decision"`
DeciderKind string `json:"decider_kind"`
CascadeMode string `json:"cascade_mode"`
Path string `json:"path"`
Email string `json:"email"`
Decision bool `json:"decision"`
DeciderKind string `json:"decider_kind"`
Chain struct {
HasAnyFile bool `json:"has_any_file"`
HasAnyFile bool `json:"has_any_file"`
// VisibleStart is the lowest chain index whose grants are
// visible to evaluation at the leaf, accounting for any
// inherit:false fence in delegated mode. In strict mode it
// is always 0 (fences are ignored under AC-6).
// inherit:false fence.
VisibleStart int `json:"visible_start"`
Levels []levelView `json:"levels"`
} `json:"chain"`
@ -403,11 +435,9 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
Email: probeEmail,
Decision: allow,
DeciderKind: deciderKind(decider),
CascadeMode: cfg.CascadeMode,
}
out.Chain.HasAnyFile = chain.HasAnyFile
mode, _ := zddc.ParseCascadeMode(cfg.CascadeMode)
out.Chain.VisibleStart = chain.VisibleStart(len(chain.Levels)-1, mode)
out.Chain.VisibleStart = chain.VisibleStart(len(chain.Levels) - 1)
// Reconstruct level paths from cfg.Root. This mirrors how
// zddc.EffectivePolicy builds the chain (see cascade.go).
@ -438,33 +468,30 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
entry := levelView{
Index: i,
ZddcPath: lp,
Exists: len(lvl.Admins) > 0 || len(lvl.ACL.Allow) > 0 || len(lvl.ACL.Deny) > 0 || len(lvl.ACL.Permissions) > 0 || lvl.ACL.Inherit != nil,
Exists: len(lvl.Admins) > 0 || len(lvl.ACL.Permissions) > 0 || lvl.ACL.Inherit != nil,
Inherit: lvl.ACL.Inherit,
}
if entry.Exists {
entry.Acl = &lvl.ACL
entry.Admins = lvl.Admins
}
// Per-level email match: would this level's deny or allow
// patterns hit the email if checked? Reuses the same
// MatchesPattern code the live evaluator does.
// Per-level email match: which permissions entry at this level
// would hit the email? Empty verbs = explicit deny; any non-
// empty verbs = grant. Mirrors GrantedVerbsAtLevel.
anyMatch := false
decisionAtLevel := "no_match"
for _, p := range lvl.ACL.Deny {
if zddc.MatchesPattern(p, probeEmail) {
anyMatch = true
for pattern, verbs := range lvl.ACL.Permissions {
if !zddc.MatchesPattern(pattern, probeEmail) {
continue
}
anyMatch = true
if verbs == "" {
decisionAtLevel = "deny"
break
}
}
if !anyMatch {
for _, p := range lvl.ACL.Allow {
if zddc.MatchesPattern(p, probeEmail) {
anyMatch = true
decisionAtLevel = "allow"
break
}
}
decisionAtLevel = "allow"
// Don't break — keep scanning so an explicit deny still
// wins over a same-level grant.
}
entry.AnyMatch = anyMatch
entry.Decision = decisionAtLevel

View file

@ -41,13 +41,26 @@ func profileTestRoot(t *testing.T, admins []string) (config.Config, *LogRing) {
}, NewLogRing(50)
}
// requestWithEmail builds a request whose context already carries email (as
// the real ACLMiddleware would inject) and whose path is path.
func requestWithEmail(method, path, email string) *http.Request {
// requestAsAdmin builds a test request whose context carries email
// AND Elevated=true — the wire shape ACLMiddleware would inject for
// a bearer-token caller or a browser session with the elevation
// cookie set. Name is the convention: every admin-action test should
// reach for THIS helper, so the call site visibly opts into admin
// authority. Tests that need to exercise the un-elevated path use
// requestAsUserMaybeElevated(method, path, email, false) explicitly —
// see the un-elevated negative tests in admin_test.go for that shape.
func requestAsAdmin(method, path, email string) *http.Request {
return requestAsUserMaybeElevated(method, path, email, true)
}
// requestAsUserMaybeElevated is the explicit form. Tests for the
// "un-elevated admin should fail closed" gate pass elevated=false.
func requestAsUserMaybeElevated(method, path, email string, elevated bool) *http.Request {
r := httptest.NewRequest(method, path, nil)
if email != "" {
r.Header.Set("X-Auth-Request-Email", email)
ctx := context.WithValue(r.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, elevated)
r = r.WithContext(ctx)
}
return r
@ -107,7 +120,7 @@ func TestServeProfileGateMatrix(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, tc.path, tc.email))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, tc.path, tc.email))
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body.String())
}
@ -118,7 +131,7 @@ func TestServeProfileGateMatrix(t *testing.T) {
func TestServeProfileWhoamiPayload(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
r := requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com")
r := requestAsAdmin(http.MethodGet, "/.profile/whoami", "alice@example.com")
r.Header.Set("X-Other-Header", "hi there")
ServeProfile(cfg, ring, nil, rec, r)
@ -159,7 +172,7 @@ func TestServeProfileConfigPayload(t *testing.T) {
cfg.CORSOrigins = []string{"https://zddc.varasys.io"}
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/config", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/config", "alice@example.com"))
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
@ -186,7 +199,7 @@ func TestServeProfileLogsPayload(t *testing.T) {
logger.Warn("second", "code", 42)
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/logs", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/logs", "alice@example.com"))
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
@ -213,7 +226,7 @@ func TestServeProfileLogsLevelFilter(t *testing.T) {
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec,
requestWithEmail(http.MethodGet, "/.profile/logs?level=warn", "alice@example.com"))
requestAsAdmin(http.MethodGet, "/.profile/logs?level=warn", "alice@example.com"))
var got []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
@ -283,7 +296,7 @@ func TestServeProfileHTMLLayered(t *testing.T) {
render := func(email string) string {
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/", email))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/", email))
if rec.Code != http.StatusOK {
t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String())
}
@ -377,7 +390,7 @@ func TestServeProfileHTMLLayered(t *testing.T) {
func TestServeProfileAccessJSON(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/access", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/access", "alice@example.com"))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
@ -394,11 +407,11 @@ func TestServeProfileAccessJSON(t *testing.T) {
}
// Subtree-admin discovery used to live in the HTML render; now it flows
// through /.profile/access. Verify the JSON endpoint exposes everything
// the IIFE needs to hydrate the Editable + Create scaffolds: AdminSubtrees
// for the read-only list, EditableParentChoices for the parent-selector
// options, and HasAnyAdminScope so the IIFE knows whether to clone the
// <template>. Pure non-admins get an empty access view and no scaffold.
// through /.profile/access. Verify the JSON endpoint exposes what the
// IIFE needs to hydrate the Editable + Create scaffolds: AdminSubtrees
// for both the read-only list AND the parent-selector options, and
// HasAnyAdminScope so the IIFE knows whether to clone the <template>.
// Pure non-admins get an empty access view and no scaffold.
func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - alice@example.com\n"), 0o644); err != nil {
@ -419,7 +432,7 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
fetchAccess := func(email string) AccessView {
t.Helper()
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/access", email))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/access", email))
if rec.Code != http.StatusOK {
t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String())
}
@ -438,16 +451,12 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
if len(carol.AdminSubtrees) != 0 {
t.Errorf("carol AdminSubtrees = %v, want empty", carol.AdminSubtrees)
}
if len(carol.EditableParentChoices) != 0 {
t.Errorf("carol EditableParentChoices = %v, want empty", carol.EditableParentChoices)
}
// Subtree-admin: AdminSubtrees lists projects/ so the create-project
// parent dropdown can offer it; HasAnyAdminScope triggers template
// hydration. The projects/.zddc is NOT editable by bob — he cannot
// edit the file that grants him his own authority — so
// EditableParentChoices is empty and the Editable-files list will
// render its "None" placeholder.
// hydration. Subtree admins own their .zddc (strict-ancestor retired),
// so bob's projects/ entry is plainly listed and the Editable-files
// list will render it inline.
bob := fetchAccess("bob@example.com")
if bob.IsSuperAdmin {
t.Errorf("bob IsSuperAdmin = true, want false")
@ -462,17 +471,11 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
for _, s := range bob.AdminSubtrees {
if strings.HasSuffix(s.Path, "/projects") {
gotProjects = true
if s.CanEdit {
t.Errorf("bob's projects/ entry CanEdit = true; he should not be able to edit the .zddc granting his own authority")
}
}
}
if !gotProjects {
t.Errorf("bob AdminSubtrees missing projects/: %+v", bob.AdminSubtrees)
}
if len(bob.EditableParentChoices) != 0 {
t.Errorf("bob EditableParentChoices = %+v, want empty (his only subtree is one he can't edit)", bob.EditableParentChoices)
}
// Super-admin: AdminSubtrees enumerates every .zddc directory.
alice := fetchAccess("alice@example.com")
@ -495,14 +498,14 @@ func TestServeProfileEffectivePolicy(t *testing.T) {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(cfg.Root, "Closed-Project", ".zddc"),
[]byte("acl:\n allow:\n - alice@mycompany.com\n"), 0o644); err != nil {
[]byte("acl:\n permissions:\n alice@mycompany.com: rwcd\n"), 0o644); err != nil {
t.Fatalf("write child .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
// Trace alice (allowed at the leaf).
rec := httptest.NewRecorder()
r := requestWithEmail(http.MethodGet,
r := requestAsAdmin(http.MethodGet,
"/.profile/effective-policy?path=/Closed-Project/&email=alice@mycompany.com",
"super@admin.com")
ServeProfile(cfg, ring, nil, rec, r)
@ -546,7 +549,7 @@ func TestServeProfileEffectivePolicy(t *testing.T) {
// Trace bob (not allow-listed; root has no broad allow either).
rec2 := httptest.NewRecorder()
r2 := requestWithEmail(http.MethodGet,
r2 := requestAsAdmin(http.MethodGet,
"/.profile/effective-policy?path=/Closed-Project/&email=bob@mycompany.com",
"super@admin.com")
ServeProfile(cfg, ring, nil, rec2, r2)
@ -590,9 +593,8 @@ func TestServeProfileEffectivePolicy_InheritFence(t *testing.T) {
zddc.InvalidateCache(cfg.Root)
type respShape struct {
Decision bool `json:"decision"`
CascadeMode string `json:"cascade_mode"`
Chain struct {
Decision bool `json:"decision"`
Chain struct {
VisibleStart int `json:"visible_start"`
Levels []struct {
Index int `json:"index"`
@ -604,7 +606,7 @@ func TestServeProfileEffectivePolicy_InheritFence(t *testing.T) {
// Trace a my-company user — fenced out at the leaf, despite root grant.
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec,
requestWithEmail(http.MethodGet,
requestAsAdmin(http.MethodGet,
"/.profile/effective-policy?path=/Vendor/&email=alice@mycompany.com",
"super@admin.com"))
if rec.Code != http.StatusOK {
@ -637,13 +639,13 @@ func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
// .zddc exists but has no admins list — page is still reachable,
// but the admin/super-admin sections are absent.
cfg, ring := profileTestRoot(t, nil)
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte("acl:\n permissions:\n \"*\": rwcd\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/", "alice@example.com"))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
@ -654,7 +656,7 @@ func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
// Per-resource gates remain.
rec = httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/whoami", "alice@example.com"))
if rec.Code != http.StatusNotFound {
t.Errorf("/.profile/whoami status = %d, want 404 (no admins configured)", rec.Code)
}
@ -676,7 +678,9 @@ func TestServeProfileProjectsCreate(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if email != "" {
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
}
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, req)
@ -684,6 +688,8 @@ func TestServeProfileProjectsCreate(t *testing.T) {
}
// Happy path: super-admin creates /alpha with no .zddc body.
// Post-refactor: the .zddc IS auto-written with the creator in
// admins: so they own the new project from birth.
rec := post("root@example.com", `{"parent":"/", "name":"alpha"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("happy path status=%d body=%s", rec.Code, rec.Body.String())
@ -691,8 +697,12 @@ func TestServeProfileProjectsCreate(t *testing.T) {
if _, err := os.Stat(filepath.Join(root, "alpha")); err != nil {
t.Errorf("alpha dir not created on disk: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err == nil {
t.Errorf(".zddc should NOT be auto-written when no fields supplied")
if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err != nil {
t.Errorf(".zddc should be auto-written with creator as admin: %v", err)
} else if zf, perr := zddc.ParseFile(filepath.Join(root, "alpha", ".zddc")); perr == nil {
if len(zf.Admins) != 1 || zf.Admins[0] != "root@example.com" {
t.Errorf("alpha .zddc Admins=%v, want [root@example.com]", zf.Admins)
}
}
// Body with a title also writes a .zddc.
@ -744,7 +754,9 @@ func TestServeProfileProjectsCreate(t *testing.T) {
// Method other than POST is 405.
req := httptest.NewRequest(http.MethodGet, "/.profile/projects", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
ctx := context.WithValue(req.Context(), EmailKey, "root@example.com")
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec = httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, req)
if rec.Code != http.StatusMethodNotAllowed {
@ -762,10 +774,12 @@ func TestServeProfileProjectsCreateValidatesZddc(t *testing.T) {
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
body := `{"parent":"/", "name":"badproject", "acl":{"allow":["bad@@glob"], "deny":[]}}`
body := `{"parent":"/", "name":"badproject", "acl":{"permissions":{"bad@@glob":"rwcd"}}}`
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
ctx := context.WithValue(req.Context(), EmailKey, "root@example.com")
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeProfile(cfg, NewLogRing(50), nil, rec, req)
@ -801,7 +815,9 @@ func TestSubtreeAdminCanCreateInScope(t *testing.T) {
body := `{"parent":"` + parent + `", "name":"` + name + `"}`
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
ctx := context.WithValue(req.Context(), EmailKey, "alice@example.com")
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeProfile(cfg, NewLogRing(50), nil, rec, req)
return rec.Code
@ -843,7 +859,7 @@ func TestServeProfileReindexPOST(t *testing.T) {
}
rec := httptest.NewRecorder()
req := requestWithEmail(http.MethodPost, "/.profile/reindex", "alice@example.com")
req := requestAsAdmin(http.MethodPost, "/.profile/reindex", "alice@example.com")
ServeProfile(cfg, ring, idx, rec, req)
if rec.Code != http.StatusOK {
@ -881,7 +897,7 @@ func TestAdminPathHardCut(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
for _, p := range []string{"/.admin/", "/.admin/whoami", "/.admin/zddc/edit?path=/"} {
rec := httptest.NewRecorder()
req := requestWithEmail(http.MethodGet, p, "alice@example.com")
req := requestAsAdmin(http.MethodGet, p, "alice@example.com")
// Calling ServeProfile directly with /.admin path: it should not match
// the /.profile prefix and so return 404. (The real-world path is
// dispatch() routing — covered in main_test.go.)

Some files were not shown because too many files have changed in this diff Show more