Replaces the URL/channel/version-fetching tool-HTML system with a
local-only override model. No network fetch, no Ed25519 signatures, no
channels/versions, no `apps:` .zddc key.
Tool HTML resolves, in precedence:
1. a real file on disk at the path (operator drops browse.html / archive.html
/ a new mytool.html) — served by the existing static handler;
2. an `<app>.html` member of the site-root <ZDDC_ROOT>/.zddc.zip bundle, read
server-side via internal/zipfs (local file, no fetch, no signature;
re-stat'd each request for free hot-reload);
3. the embedded //go:embed default.
Remove (complete unwire):
- internal/apps/{fetch,verify,cache,singleflight}.go and their tests; the
spec-parsing/cascade machinery in apps.go (ParseSpec/Resolve/PreviewLine/
SpecComponents/appsState, DefaultUpstream*/DefaultChannel/CacheDirName).
- --apps-pubkey / ZDDC_APPS_PUBKEY flag+env+Config field; the setupApps
cache/fetcher/pubkey wiring (now just apps.NewServer(root, version)).
- the `apps:` / `apps_pubkey:` .zddc keys: ZddcFile.Apps/AppsPubKey, the
walker merges, cascade-summary adds, validate.go apps validation
(ValidateAppSourceSpec/validateURLSpec/validateChannelOrVersion/
AppsDefaultKey/IsValidAppsKey), and the isZero/is-empty refs. A stale
apps:/apps_pubkey: in an existing .zddc is now silently ignored
(back-compat), not a parse error. Client .zddc validator (preview-yaml.js)
drops the apps/apps_pubkey keys + appsmap case.
Add:
- internal/apps/bundle.go — nil-safe Bundle over <root>/.zddc.zip with
stat-based hot-reload, size caps, corrupt-zip tolerance.
- handler.go: Server{Bundle}, resolveBytes (bundle→embedded), simplified
Serve; X-ZDDC-Source = bundle:<m> / embedded:<app>@<ver>.
- dispatch: GET /.zddc.zip is 404 for everyone (config, not content); the
server reads members from the filesystem internally.
Tests: new bundle_test.go (member hit/absent/no-file/hot-reload/corrupt);
handler_test.go rewritten for bundle-overrides-embedded, absent-member→
embedded, unknown-tool 503, conditional-GET for both sources; dispatch test
covers bundle override + /.zddc.zip 404 + availability rules. go build/vet/
test ./... all green; gofmt clean. Docs (AGENTS.md, ARCHITECTURE.md) updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two users editing the same file online could silently clobber each other:
the editor's save did a bare PUT with no precondition, even though the master
already enforces optimistic concurrency (fileapi.go checkIfMatch → 412). Now
the editor sends a precondition and surfaces a conflict UI instead of
overwriting.
- util.js: saveFile(node, content, contentType, opts) sends `If-Match: <etag>`
(or `If-Unmodified-Since` fallback) unless opts.force; returns {etag} from
the PUT response (so save→edit→save adopts the new version and doesn't
false-conflict); throws ConflictError (.status===412) on a precondition
failure so callers branch cleanly. New saveCopy() parks a conflicting edit
as `<stem>-conflict-<ts>.<ext>` (collision-probed) without losing either side.
- preview.js: getContentWithVersion(node) → {buf, etag, lastModified} captured
from the content GET (the listing JSON carries no per-file etag); threaded
into the editor ctx and exported. getArrayBuffer left untouched.
- conflict.js (new): shared, callback-driven dialog — mine-vs-theirs diff
(reuses zddc.diff + css/history.css) + Overwrite / Reload-theirs /
Save-a-copy / Cancel. Never calls saveFile/showFilePreview itself, so the
deferred Phase 5 cache-outbox conflict UI can reuse it with its own callbacks.
- preview-markdown.js / preview-yaml.js: capture + forward the version token,
adopt the returned etag on success, and on 412 open the dialog (Overwrite
re-fetches the current etag then re-saves — re-conflicts on a third writer
rather than blind-forcing; Reload clears dirty first so the renderInline
guard skips its confirm). FS-Access mode sends no precondition (no
concurrency) and never conflicts.
- build.sh: concat conflict.js after util.js.
- tests/conflict.spec.js (+ playwright project): If-Match sent, ConflictError
on 412, new-etag returned, force omits the precondition, dialog renders the
diff and each action resolves via its callback. Drives the fresh dist build
over file:// with a stubbed fetch (the test binary embeds the committed
browse.html, not dist, so a server-mode E2E would run stale code).
All browse + diff + conflict specs pass (18).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nine copies of escapeHtml (some escaping single-quotes + handling null,
others not), two byte-identical hashContent hashers, two saveContent
writers, two isZipMemberNode predicates, the ISO-date + YAML-quote helpers
duplicated across the workflow modals, three /.profile/access email
fetchers, and three byte-size formatters had all drifted across the browse
modules. Hoist a single browse-local window.app.modules.util (no new global;
concatenated right after init.js) and alias the call sites to it.
Reliability fix folded in: the YAML editor's saveContent skipped the
upload.ensureWritable() escalation that the markdown editor performs, so
saving a .yaml/.zddc file to a read-only-picked local folder failed where
markdown succeeded. Both now go through util.saveFile, which always
escalates — the shared writer makes the two editors impossible to drift
apart again.
Canonical escapeHtml is the strict superset (escapes & < > " ', null →
"") so it's a safe drop-in for every prior variant. fmtSize gains the GB
tier everywhere (history.js previously capped at MB). Also removes the dead
stage.js fetchSelfEmail (defined, never called).
Net −200 lines across the modules. No behavior change beyond the save fix;
all 6 browse Playwright specs pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The markdown/YAML preview editors were never disposed when switching to a
non-editor file: dispose() was only called from inside the same plugin's
render(), so md→PDF/image/YAML overwrote the pane via innerHTML and leaked
the Toast UI instance, its DOM, and document-level resizer drag listeners.
Unsaved edits were also discarded silently on any file switch (including
arrow-key auto-preview), and debounced change handlers could resolve after
an editor was disposed and write the wrong file's dirty/hash state.
preview.js now owns editor lifecycle centrally in renderInline:
- disposeEditors() up front before replacing the pane (fixes the leak for
every md/yaml → anything switch).
- dirty guard: deliberate switches (click/Enter/menu) confirm before
discarding; auto previews (keyboard cursor walking the tree, opts.auto)
leave the dirty editor in place rather than nagging per keystroke;
re-selecting the file already being edited is a no-op.
- a renderSeq token bails late-arriving loads so a slow file can't paint
stale content into the pane after a newer selection.
- clearPreview() exposed and used by rescope (events.js) and popstate
(app.js) so those resets dispose the editor instead of leaking it.
- beforeunload warns when an editor is dirty at page exit.
preview-markdown.js: per-mount AbortController wired into the resizer
document listeners so dispose() detaches them even mid-drag; debounced
change/save/convert handlers guard `currentInstance !== instance` so a
disposed editor's callbacks can't corrupt the active file; expose
isDirty()/currentNode().
preview-yaml.js: track dirty/node state, guard the change handler the same
way, expose dispose()/isDirty()/currentNode().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The YAML viewer mounted CodeMirror with readOnly:'nocursor', which removes
the textarea from focus and kills click-drag selection — so copying a
read-only .zddc snippet was impossible without flipping on admin mode to
make it writable. Use readOnly:true instead: non-editable but focusable,
so the user can select and copy. autofocus:false still keeps arrow-key
tree navigation intact until they click into the editor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Browse's row context menu and in-place editors now consult the
server-computed verbs string (via window.zddc.cap.has) before
enabling write/delete affordances:
- Rename… disables when the entry's verbs lacks 'w'.
- Delete… disables when verbs lacks 'd'.
- Markdown editor mounts read-only when verbs lacks 'w'.
- YAML editor mounts read-only when verbs lacks 'w' for regular
files, 'a' for the .zddc placeholder (matches the file API's
ActionAdmin gate at that URL).
Disabled menu items carry a tooltip naming the missing access
("You don't have write access to this item.") so the user discovers
which permission is missing rather than just seeing a greyed row.
shared/context-menu.js gains a `tooltip` field (string or fn(ctx))
that sets the row's title attribute.
canMutate() stays as the source-side gate (server vs FS-API
reachability, zip-member / virtual filtering); verbs gate composes
on top. Server-side ACL still has the final say if a stale client
ever tries the action.
cap.has() falls back to node.writable for 'w' when verbs is absent,
so offline FS-API mode keeps working without a server.
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>
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>
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>
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>