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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Adds a UI checkbox next to the existing Sort dropdown that surfaces
hidden entries when ACL would otherwise allow read. Default off
(matches today's filtered behavior). On toggle, browse re-fetches
the current directory with ?hidden=1 and re-renders.
┌─ browse toolbar ─────────────────────────────────────────────┐
│ Sort: [Name (A→Z) ▾] ☐ Show hidden │
└──────────────────────────────────────────────────────────────┘
Server-side surface:
- internal/fs/tree.go ListDirectory gains an `includeHidden bool`
parameter. The .-prefix filter (previously hard-coded) now also
drops _-prefix entries (matches dispatch's reserved-prefix guard)
and honors the new flag.
- internal/handler/directory.go reads `?hidden=1` from the request
and threads it through.
- cmd/zddc-server/main.go dispatcher relaxes its dot-prefix and
_-prefix guards for GET/HEAD when `?hidden=1` is set, so clicking
a hidden entry's link works. `_app/` (apps cache) stays
unconditionally reserved — those bytes must go through the apps
resolver. Writes to hidden paths stay blocked (the file API has
its own segment check that the flag does NOT relax).
- internal/listing/listing.go: signature parity (the lower-level
helper that's used by tests + non-cascade listing paths).
Security model unchanged: the ACL chain on the parent dir is the only
real gate. Whoever can read the dir can see its contents — toggling
"Show hidden" just stops the client-side filter from masking
.-prefixed and _-prefixed entries. Hidden paths today:
• <dir>/.zddc ACL YAML — already exposed via /.profile/zddc
• <dir>/.converted/<base> cached MD→DOCX/HTML/PDF, same sensitivity as source
• <root>/.zddc.d/tokens/ per-token metadata; filename = sha256(token)
so not bearer-usable. Default root ACL
restricts to admins; matches /.tokens UI.
• <root>/.zddc.d/logs/ access logs; same admins-only audience
• <root>/_app/ cached upstream tool HTML (public)
• <root>/_template/ install.zip scaffolding (public)
None of these contain bearer credentials or secret material that the
existing ACL doesn't already gate. The walls are still the cascade.
The HTML→PDF path produced PDFs where content extended past the
right margin of each letter page. Two contributing causes in
viewer-template.html's @media print rules:
1. .content-wrapper carries max-width: min(900px, 100%) from the
screen layout. The print override set width: 100% but didn't reset
max-width. Chromium's --print-to-pdf renders at the full page
width (816px for letter at 96dpi) and only clips at print time,
so without max-width: none the element actually extends past the
~624px printable area.
2. Tables, preformatted blocks, and long URLs had no print
containment. A wide <pre> or a <table> with many columns would
blow out the right edge even when the parent constraints held.
Fixes applied to @media print:
- html, body, .app-container: explicit width: 100% + max-width: 100%
to be sure the print viewport flows top-down with no horizontal
creep at the layout root.
- .content-wrapper: max-width: none + width: 100% (was just width).
- .content-page: width: 100% added (was just max-width: none).
- .document-content: max-width: 100% + box-sizing: border-box so
the existing 0.5in horizontal padding stays inside the page.
- pre/code/table/blockquote/img/video: max-width: 100% +
overflow-wrap: break-word; <pre> additionally white-space:
pre-wrap + word-break: break-word so unbreakable token runs
(URLs, paths, command lines) wrap instead of overflowing.
- table: table-layout: fixed so columns shrink to fit rather than
forcing horizontal scroll/overflow.
Both source files (pandoc/viewer-template.html and the embed copy at
zddc/internal/convert/viewer-template.html) updated and verified
identical with diff -q.
The HTML→PDF stage failed with:
Creating shared memory in /dev/shm/.org.chromium.Chromium.XXXXXX
failed: Read-only file system (30)
Unable to access(W_OK|X_OK) /dev/shm: Read-only file system (30)
Chromium tries to put its IPC shared-memory segments under /dev/shm
by default. Our container runs --read-only with /dev/shm inherited
from the image (which makes it read-only too). The well-known fix is
the --disable-dev-shm-usage chromium flag, which routes those
allocations to /tmp instead.
/tmp is a writable tmpfs we already set up. Bump its size from
128 MiB to 256 MiB so chromium has room for both its user-data-dir
and the redirected shared-memory segments. A small PDF flow used
~64 MiB free of 128 MiB available; doubling gives headroom without
materially changing the pod's memory footprint (tmpfs only consumes
RAM for bytes actually written).
The discardable_shared_memory_manager warning ("Less than 64MB of
free space in temporary directory") in the prior chromium log was a
symptom of this same /tmp-too-small condition; the bump quiets it
too.
Other warnings in the log (dbus connect failures) are not load-
bearing — chromium falls back gracefully when dbus is absent. No fix
needed there.
Drops the `download` attribute and adds `target="_blank"`. Click now
opens the conversion URL in a new tab; the server's
Content-Disposition: inline routes each format to a sensible
behavior in that tab:
• HTML → renders as a web page
• PDF → opens in the browser's PDF viewer
• DOCX → auto-downloads (browser can't render Office Open XML),
tab is transient
Right-click → "Save Link As" still works for explicit save-to-disk.
Side benefit for debugging: when the conversion endpoint returns
422 or 503, the response body appears as a plain-text page in the
new tab, which is easier to triage than a transient toast.
Right-click → "Copy Link Address" / "Open in New Tab" / "Save Link
As" now work natively, so users can share the conversion URL or pick
their own download path. The buttons are styled <a href> elements
instead of <button>, with the `download` attribute set to the
expected filename (foo.docx etc.) so a plain click still downloads.
Click handler simplifies a lot: on clean buffer, the handler returns
immediately and the browser handles the navigation. On dirty buffer,
the handler intercepts, auto-saves, then re-fires the click — which
re-enters the handler with dirty=false and falls through to the
native navigation. No more JS fetch + blob + objectURL plumbing for
the common path.
Side effect: if the server returns 422 or 503, the browser shows the
response body in the target tab. That's less polished than the
previous toast, but it's also a more direct view of what the server
actually said. The toast path stays in shared/zddc-source.js's
downloadConverted helper for tools that prefer the JS-driven flow.
zddc-server can now invoke podman as a CLIENT against a remote socket
instead of creating containers in its own process. The sidecar pattern
in tnd-zddc-chart will use this so zddc-server's own pod stays
unprivileged (only the podman-system-service sidecar runs privileged).
New surface:
--convert-podman-socket / ZDDC_CONVERT_PODMAN_SOCKET
e.g. unix:///var/run/podman/podman.sock
Empty (default) → local mode (podman creates containers in
zddc-server's own filesystem namespace).
Non-empty → remote mode: `podman --remote --url=<this> run …`
dispatches each container request to whatever process owns the
socket. Typically a `podman system service` sidecar in the same
Kubernetes pod.
--convert-scratch-dir / ZDDC_CONVERT_SCRATCH_DIR
Host-side directory for per-conversion intermediates (template,
HTML, PDF). In remote mode this MUST be a path the sidecar sees
at the same mountpoint — typically a shared emptyDir at /work
in both containers. Empty = $TMPDIR (local-mode default).
Runner behaviour:
local mode → unchanged. `podman run --userns=host --rm --pull=missing
--network=none --read-only …`. `--userns=host` stays so nested-podman
on a privileged host (the previous chart shape) keeps working for
anyone still using it.
remote mode → `podman --remote --url=<sock> run --rm --pull=missing
--network=none --read-only …`. `--userns=host` is dropped because
the sidecar is rootful inside its own privileged container and
doesn't need userns juggling.
Health probe gains a Mode field ("local" | "remote") and, in remote
mode, runs `podman --remote --url=<sock> version` to confirm the
sidecar's socket is reachable. Unreachable-socket → 503 with a clear
reason (sidecar may still be starting up); reachable → ready.
Capabilities log now includes engine_version + mode + remote_url for
easier debugging of "which podman is actually doing the work".
No tests removed — the existing fake-runner table covers both modes
since the runner's args are uniform (remote prefix is the only thing
that differs).
When zddc-server runs inside a Kubernetes pod and shells out to
`podman run`, the inner podman tries to set up its own user namespace
via /usr/bin/newuidmap. The mapping fails inside the pod's namespace
even with privileged: true:
newuidmap: write to uid_map failed: Invalid argument
Error: cannot set up namespace using "/usr/bin/newuidmap": exit status 1
Adding --userns=host to the inner `podman run` tells it to reuse the
caller's user namespace instead of creating a new one — newuidmap
isn't invoked. The chart already runs the pod privileged so reusing
its userns adds no new privilege; --cap-drop=ALL + --network=none +
--read-only + --tmpfs continue to isolate the inner container.
On a bare-metal host invocation, --userns=host means "no userns
remapping at all", which is the default for rootful podman and works
identically to the prior behavior — the bitnest test setup and any
laptop dev runs are unaffected.
Smoke-tested locally with the exact flag set: pandoc/latex:latest in
a --userns=host --read-only container produces valid HTML from
`# Hello world` on stdin.
The on-page label for `--release alpha|beta` cuts used to read
`vX.Y.Z-channel · YYYY-MM-DD · word-word-word`. The three-word slug
(derived deterministically from the source SHA via shared/build-words.txt)
was meant to make "is this the same build you emailed about?" a glance
check, but the cute-words layer turned out to be more confusing than
clarifying — testers prefer a real timestamp + short SHA.
New label shape, identical to plain dev builds:
vX.Y.Z-channel · YYYY-MM-DD HH:MM:SS · <short-source-sha>[-dirty]
Helper renamed from _source_commit_slug to _source_commit_short_sha,
returning the short SHA of the source commit (walking past any
`chore(embedded): cut …` commit at HEAD so a re-cut on unchanged
source produces the same SHA). The wordlist file is no longer
referenced and is removed; tests/build-label.spec.js's regex
simplified to require the full timestamp + SHA form.
The markdown editor's DOCX/HTML/PDF download buttons silently no-op'd
because the gate `typeof window.zddc.source.downloadConverted ===
'function'` always failed: browse rolls its own server-mode detection
(state.source === 'server' + node.url) and never needed the shared/
zddc-source.js polyfill before. The new download helper lives on
window.zddc.source, so browse needs to bundle it.
Adds ../shared/zddc-source.js to browse/build.sh's concat_files list,
right after preview-lib.js. Bundle gains ~12 KB; tools that don't
need the polyfill (browse doesn't use HttpDirectoryHandle directly)
pay a small footprint cost in exchange for getting the helper.
mdedit/ is gone. Its functionality moved into browse's preview plugin
(browse/js/preview-markdown.js) — YAML front matter editing, outline,
and on-demand DOCX/HTML/PDF download all happen there. Browse is the
default_tool for working/ + reviewing/ as of the previous commit, so
existing URLs of the form /<project>/working land on browse without
operator action.
Removed:
• mdedit/ source tree (Toast UI app, CSS, JS, template, build.sh)
• zddc/internal/apps/embedded/mdedit.html (//go:embed blob)
• tests/mdedit.spec.js + the "mdedit" project in playwright.config.js
• mdedit entries in zddc/internal/apps/embed.go (//go:embed, var,
switch case in EmbeddedBytes)
• "mdedit" in zddc/internal/zddc/validate.go AppNames + the matching
error-message app list
• "mdedit.html" branch in zddc/internal/apps/handler.go MatchAppHTML
• mdedit case in tests (handler_test.go, validate_test.go,
zddchandler_test.go) — test fixtures now use browse/classifier
• mdedit from build (per-tool build.sh loop, tool-list literals,
composer cards) and shared/build-lib.sh ZDDC_RELEASE_TOOLS
• mdedit from freshen-channel's tool list and usage banner
• mdedit-specific paragraphs in AGENTS.md and ARCHITECTURE.md;
Markdown Editor section in ARCHITECTURE.md rewritten to point at
browse/js/preview-markdown.js
• mdedit from CLAUDE.md, README.md, zddc/README.md tool lists
Historical mdedit_v*.html / mdedit_v*.html.sig files in
/srv/zddc/releases/ on the deploy host are immutable history — they
stay where they are. The next ./build release cut will simply not
produce new mdedit_v* artifacts.
Flip default_tool from `mdedit` to `browse` (which now ships a Toast UI
markdown editor plugin in its preview pane) at:
• paths."*".paths.working
• paths."*".paths.working.paths."*" (per-user homes)
• paths."*".paths.reviewing
available_tools at those levels drops `mdedit` and adds `browse` next
to `classifier`. Operator overrides per .zddc cascade still work; only
the embedded baseline changes.
Test fixtures updated:
• lookups_test.go — DefaultToolAt assertions for working/+reviewing/
• availability_test.go — AppAvailableAt + DefaultAppAt for working/+
reviewing/+per-user home
• main_test.go — dispatch route asserts "ZDDC Browse" (was "ZDDC
Markdown"); Apps cascade fixture swaps mdedit
for browse so the live route fetches the right
embedded HTML