From 55328c8c282cdf6574617773afbbc866793c3ae6 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 18 May 2026 09:42:36 -0500 Subject: [PATCH] feat(browse): editors honor server-side write authority + don't steal focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- browse/css/base.css | 16 ++++++++ browse/js/loader.js | 5 +++ browse/js/preview-markdown.js | 64 ++++++++++++++++++++++-------- browse/js/preview-yaml.js | 23 +++++++++-- zddc/internal/fs/tree.go | 26 +++++++++++- zddc/internal/fs/tree_test.go | 16 ++++---- zddc/internal/handler/directory.go | 2 +- zddc/internal/listing/types.go | 15 +++++++ 8 files changed, 137 insertions(+), 30 deletions(-) diff --git a/browse/css/base.css b/browse/css/base.css index 1d2b5ff..38f7de4 100644 --- a/browse/css/base.css +++ b/browse/css/base.css @@ -40,3 +40,19 @@ body { .status-bar--error { color: #b00020; } .status-bar--info { color: var(--primary); } + +/* Read-only banners — surfaced by preview-markdown.js / preview-yaml.js + when the listing's `writable` bit was false. Inline at the top of + the editor host so the user can't miss it; muted styling so it + doesn't fight the editor chrome. */ +.md-readonly-banner, +.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; +} diff --git a/browse/js/loader.js b/browse/js/loader.js index 0621aa0..dd51bf6 100644 --- a/browse/js/loader.js +++ b/browse/js/loader.js @@ -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 }; diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index fc4a996..d23e29f 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -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; @@ -510,24 +515,50 @@ 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%', - // 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', - 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). Clear visual signal that the + // file isn't editable — much better than letting the user + // type into an Editor whose Save button is disabled. + editor = window.toastui.Editor.factory(Object.assign({}, editorOpts, { + viewer: true, + })); + // Read-only banner above the editor explains why. + var roBanner = document.createElement('div'); + roBanner.className = 'md-readonly-banner'; + roBanner.innerHTML = '' + + ' Read-only — you don\'t have write access to this file.' + + ' Save is disabled; changes won\'t persist.'; + editorHost.insertBefore(roBanner, editorHost.firstChild); + } 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, @@ -539,8 +570,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; diff --git a/browse/js/preview-yaml.js b/browse/js/preview-yaml.js index f2568d3..47b9ac1 100644 --- a/browse/js/preview-yaml.js +++ b/browse/js/preview-yaml.js @@ -77,6 +77,10 @@ function canSave(node) { if (isZipMemberNode(node)) return false; if (node.virtual) 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; @@ -454,6 +458,7 @@ window.CodeMirror.__zddcYamlLinterReady = true; } + var writable = canSave(node); var editor = window.CodeMirror(editorHost, { value: text, mode: 'yaml', @@ -463,7 +468,15 @@ indentWithTabs: false, lineWrapping: false, gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'], - lint: { hasGutters: true } + 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. @@ -472,11 +485,15 @@ editor.performLint(); currentEditor = editor; - var writable = canSave(node); if (!writable) { saveBtn.disabled = true; saveBtn.title = 'Save not available — read-only source.'; - editor.setOption('readOnly', true); + // Read-only banner above the editor explains why. + var roBanner = document.createElement('div'); + roBanner.className = 'yaml-readonly-banner'; + roBanner.innerHTML = '' + + ' Read-only — you don\'t have write access to this file.'; + editorHost.insertBefore(roBanner, editorHost.firstChild); } var initialHash = await hashContent(text); diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index fa9c93d..db15ecc 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -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{} } @@ -93,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, false) + for _, entry := range entries { name := entry.Name() @@ -162,6 +172,20 @@ 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. + fileURL := baseURL + name + if parentActiveAdmin { + fi.Writable = true + } else { + allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, policy.ActionWrite) + if allowed { + fi.Writable = true + } + } result = append(result, fi) } diff --git a/zddc/internal/fs/tree_test.go b/zddc/internal/fs/tree_test.go index 31e74e8..e3610be 100644 --- a/zddc/internal/fs/tree_test.go +++ b/zddc/internal/fs/tree_test.go @@ -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) } diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index e399d22..b915802 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -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) diff --git a/zddc/internal/listing/types.go b/zddc/internal/listing/types.go index 93e8747..013326f 100644 --- a/zddc/internal/listing/types.go +++ b/zddc/internal/listing/types.go @@ -46,4 +46,19 @@ type FileInfo struct { // page project names without a separate API. Empty when the // entry has no `.zddc` or its `.zddc` doesn't set `title:`. Title string `json:"title,omitempty"` + + // Writable surfaces whether the calling principal has write + // authority on this entry — answered by running the policy decider + // with ActionWrite on the entry's path, with the same admin-bypass + // branch that the file API uses. Clients (notably the in-place + // markdown editor) read this to mount in read-only mode when a + // PUT would 403, instead of letting the user type then lose their + // changes on save. + // + // omitempty: the listing JSON omits the field when false so the + // wire payload doesn't bloat; clients should treat absence as + // false-or-unknown and gate writes accordingly. Read-only-by- + // default is the safer client-side fallback if the server forgets + // to populate it. + Writable bool `json:"writable,omitempty"` }