From 56c3353f7bf2f119ddfe942172ea52b76a4e5705 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 1 Jun 2026 10:39:49 -0500 Subject: [PATCH 1/2] feat(browse): party picker for New folder/file in virtual aggregators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a folder/file at a project-level folder-nav aggregator root (working/staging/reviewing) used to error or silently shadow — the slots are virtual and content is party-scoped. Now browse opens a party picker that targets archive///, with a "+ New party…" option (server-gated to the document_controller via the existing archive/ ACL). - events.js: aggregatorRoot detection + openPartyPicker modal (mirrors the stage.js modal), createInAggregator routes the create to the canonical archive path; rewriteAggregatorPath handles right-clicking a party row shown in an aggregator listing so it never re-prompts. - server: serveFileMkdir now 409s a mkdir inside an aggregator (rejectProjectAggregatorMkdir) with a pointer at archive///, instead of letting the write fall through to an unreachable shadow dir. Reverts the prior session's project-level creator-owned working/ folders (per the design decision to make all three folder-nav slots uniformly party-scoped): working/ is a pure virtual aggregator again like staging/reviewing — drops the working/ history+auto_own+acl defaults, the EnsureCanonicalAncestors working exception, the working-root document- controller file gate (serveFilePut/Move) and zddc.IsRoleMemberAt. Per-party archive//working/ keeps its own history + auto-own. Co-Authored-By: Claude Opus 4.8 (1M context) --- ARCHITECTURE.md | 4 +- browse/js/events.js | 177 ++++++++++++++++++++++++++ zddc/cmd/zddc-server/main_test.go | 20 +-- zddc/internal/handler/fileapi.go | 79 ++++++------ zddc/internal/handler/fileapi_test.go | 114 ++++------------- zddc/internal/zddc/defaults.zddc.yaml | 40 +----- zddc/internal/zddc/ensure.go | 36 ++---- zddc/internal/zddc/ensure_test.go | 54 +------- zddc/internal/zddc/lookups.go | 17 --- zddc/internal/zddc/lookups_test.go | 17 ++- 10 files changed, 281 insertions(+), 277 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 34e8f08..0f8489d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -684,9 +684,9 @@ There are **no hardcoded folder names** — the canonical project structure is d **Project shape (after the May 2026 reshape).** `archive/` is the only physical project-root directory. Everything party-scoped lives uniformly under `archive//{ssr.yaml, mdl/, rsk/, received/, issued/, incoming/, working//, staging//, reviewing//}`. Six sibling top-level URLs are **virtual aggregators**, never on disk: - **Row rollups** (tables tool, `default_tool: tables`) — `/ssr`, `/mdl`, `/rsk`. Synthesise one row per party (SSR) or per row file across parties (MDL/RSK), with the source party injected as a synthesised `$party` column. The `$` sigil marks the column system-managed: the tables tool renders it read-only and strips it before submitting a write. Form-mode "+ Add row" on a rollup view prompts for `party` (the routing key, stored in the form schema as a real input field; stripped on write because the folder name *is* the identity). -- **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `/working`, `/staging`, `/reviewing`. List the parties whose `archive///` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `//[/]` 302-redirect to the canonical `/archive//[/]`. (A party name fails `ValidPartyName` only if it contains a character outside `[A-Za-z0-9.-]` — the resolver then declines to redirect and the path is treated physically; see `working/` below.) Sharing/bookmarks land on the canonical path after the redirect. +- **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `/working`, `/staging`, `/reviewing`. All three are purely virtual (no on-disk presence); they list the parties whose `archive///` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `//[/]` 302-redirect to the canonical `/archive//[/]`. Sharing/bookmarks land on the canonical path after the redirect. - `working/` is the one folder-nav aggregator that **also materialises on disk**: it doubles as a shared project-level drafting space holding **creator-owned working folders** at `/working//`. The slot dir is instantiated lazily by `EnsureCanonicalAncestors` the first time real content is created beneath it (it stays a plain dir — never auto-owned), and each `/` a user creates gets an *unfenced* auto-own `.zddc` (`history: true` inherits in, so markdown drafts there are versioned). Authorisation splits dir-vs-file at the root: project members may create folders (`project_team: rc` in the defaults), but a **bare file directly at the `working/` root is reserved for the `document_controller`** — regular users work inside a folder; the DC creates files at the root or promotes one up with a MOVE. Enforced in `serveFilePut`/`serveFileMove` via `isProjectWorkingRootFile` + `zddc.IsRoleMemberAt`, independent of the ACL verb (since mkdir and file-PUT both authorise as `ActionCreate`). The earlier per-user `working//` "personal workspace" idea was dropped as more complexity than it earned. `staging`/`reviewing` remain non-materialising — `EnsureCanonicalAncestors` still rejects physical writes under them. + **Creating in an aggregator → party picker.** Because content is party-scoped, there's nowhere to put a folder/file at `//` itself. `EnsureCanonicalAncestors` rejects any physical write under all six aggregator names, and `serveFileMkdir` additionally 409s a mkdir *inside* one (`rejectProjectAggregatorMkdir`) with a message pointing at `archive///` — rather than silently materialising an unreachable shadow folder. The browse "New folder" / "New file" action detects an aggregator root client-side (`events.js: aggregatorRoot`) and opens a **party picker** (`openPartyPicker`, styled like the stage modal): the user chooses an existing party — or "+ New party…", which issues `mkdir archive//…` and is gated to the `document_controller` by the existing `archive/` create ACL (a 403 surfaces a clear message). The chosen folder/file is then created at `archive///`, where the per-party `working/` slot carries its own `history: true` + auto-own convention. Right-clicking a party row shown in an aggregator listing rewrites to the canonical path directly (`rewriteAggregatorPath`), so it never re-prompts. (An earlier iteration made `/working/` materialise project-level creator-owned folders directly; that was dropped in favour of this uniform party-scoped model.) Mkdir at the project root is restricted: only `archive` and `_`/`.`-prefixed system names are accepted (`handler/fileapi.go: rejectProjectRootMkdir`). Any other name — including the six virtual aggregator names, which would shadow the virtual surface — returns 409 Conflict. This is the only structural mkdir guard; deeper paths are governed by `auto_own:` + `worm:` + ACL. diff --git a/browse/js/events.js b/browse/js/events.js index 3b1b52c..c2c620e 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -660,9 +660,186 @@ return parentDir; } + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; + }); + } + + // Valid party folder name — mirrors zddc.ValidPartyName server-side + // (^[A-Za-z0-9][A-Za-z0-9.-]*$). + function validPartyName(s) { return /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(s || ''); } + + // The project-level folder-nav aggregators. These have no physical + // presence: // lists the parties whose + // archive/// has content. Creating something here means + // creating it under a party — see createInAggregator. + var FOLDER_NAV_SLOTS = { working: 1, staging: 1, reviewing: 1 }; + + // aggregatorRoot returns { project, slot } when parentDir is a + // project-level folder-nav aggregator root (server mode only), else + // null. parentDir is a "///" URL. + function aggregatorRoot(parentDir) { + if (state.source !== 'server') return null; + var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/'); + if (segs.length !== 2 || !segs[0]) return null; + var slot = segs[1].toLowerCase(); + return FOLDER_NAV_SLOTS[slot] ? { project: segs[0], slot: slot } : null; + } + + // rewriteAggregatorPath maps a path UNDER a folder-nav aggregator + // (a party already chosen — ///[/]) to its + // canonical archive path //archive//[/], + // mirroring the server's folder-nav redirect. Returns null when + // parentDir isn't under such an aggregator (root case is handled by + // aggregatorRoot + the picker). Covers right-clicking a party row + // shown in an aggregator listing so "New folder" doesn't 409. + function rewriteAggregatorPath(parentDir) { + var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/'); + if (segs.length < 3 || !segs[0]) return null; + var slot = segs[1].toLowerCase(); + if (!FOLDER_NAV_SLOTS[slot]) return null; + var p = '/' + segs[0] + '/archive/' + segs[2] + '/' + slot + '/'; + var rest = segs.slice(3); + if (rest.length) p += rest.join('/') + '/'; + return p; + } + + // List the parties under a project's archive/ (folder names), sorted. + async function fetchParties(project) { + try { + var entries = await loader.fetchServerChildren('/' + project + '/archive/'); + return entries + .filter(function (e) { return e.isDir; }) + .map(function (e) { return e.name; }) + .sort(function (a, b) { return a.localeCompare(b); }); + } catch (_e) { return []; } + } + + // openPartyPicker resolves to { party, name } once the user picks a + // party (existing or new) and a name, or null on cancel. Mirrors the + // stage.js modal styling. New-party creation is offered but the server + // gates it to the document_controller (a 403 surfaces a clear message). + function openPartyPicker(opts) { + return new Promise(function (resolve) { + var kindWord = opts.kind === 'folder' ? 'folder' : 'file'; + var overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + var box = document.createElement('div'); + box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);'; + + var partyList = opts.parties.map(function (name) { + return ''; + }).join(''); + + box.innerHTML = + '

New ' + kindWord + ' in ' + escapeHtml(opts.slot) + '/

' + + '

' + + escapeHtml(opts.slot) + '/ aggregates each party’s work, so it has no folder of its own. ' + + 'Pick the party this ' + kindWord + ' belongs to — it lands under archive/<party>/' + escapeHtml(opts.slot) + '/.' + + '

' + + '
' + + (partyList || 'No parties yet — create one below.') + + '' + + '
' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
'; + overlay.appendChild(box); + document.body.appendChild(overlay); + + var newRow = box.querySelector('#pp-newparty-row'); + var newInput = box.querySelector('#pp-newparty'); + box.querySelectorAll('input[name="pp-party"]').forEach(function (r) { + r.addEventListener('change', function () { + var isNew = (r.value === '__new__' && r.checked); + newRow.style.display = isNew ? '' : 'none'; + if (isNew) newInput.focus(); + }); + }); + + function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } + function cancel() { close(); resolve(null); } + box.querySelector('#pp-cancel').addEventListener('click', cancel); + overlay.addEventListener('click', function (e) { if (e.target === overlay) cancel(); }); + box.querySelector('#pp-submit').addEventListener('click', function () { + var sel = box.querySelector('input[name="pp-party"]:checked'); + if (!sel) { statusError('Pick a party.'); return; } + var party; + if (sel.value === '__new__') { + party = newInput.value.trim(); + if (!validPartyName(party)) { + statusError('Party name: a letter or digit, then letters/digits/dot/hyphen.'); + return; + } + } else { + party = sel.value; + } + var nv = validateName(box.querySelector('#pp-name').value); + if (!nv.ok) { statusError(nv.msg); return; } + close(); + resolve({ party: party, name: nv.name }); + }); + }); + } + + // createInAggregator routes a New folder/file in a virtual aggregator + // root to archive/// after prompting for the party. + async function createInAggregator(agg, kind) { + var up = window.app.modules.upload; + if (!up) return; + var parties = await fetchParties(agg.project); + var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties }); + if (!choice) return; + // Party names are validated to a URL-safe charset, so no encoding + // needed for the party segment; makeDir/makeFile encode the leaf. + var targetDir = '/' + agg.project + '/archive/' + choice.party + '/' + agg.slot + '/'; + try { + if (kind === 'folder') { + await up.makeDir(targetDir, choice.name); + statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + choice.name); + } else { + var name = /\.(md|markdown)$/i.test(choice.name) ? choice.name : choice.name + '.md'; + var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n'; + await up.makeFile(targetDir, name, template, 'text/markdown; charset=utf-8'); + statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + name); + } + } catch (e) { + var msg = (e && e.message) || String(e); + if (/\b403\b/.test(msg)) { + statusError('Not allowed — creating a new party requires the document-controller role.'); + } else { + statusError('Create failed: ' + msg); + } + return; + } + // Refresh the aggregator view — the party now appears if it had no + // content before. + await reloadDir('/' + agg.project + '/' + agg.slot + '/'); + } + async function createInDir(parentDir, kind) { var up = window.app.modules.upload; if (!up) return; + // A project-level folder-nav aggregator (working/staging/reviewing) + // has no physical home — route through the party picker instead of + // erroring on an unplaceable mkdir/PUT. + var agg = aggregatorRoot(parentDir); + if (agg) return createInAggregator(agg, kind); + // A party already chosen inside an aggregator view → canonical path. + var rewritten = rewriteAggregatorPath(parentDir); + if (rewritten) parentDir = rewritten; var promptMsg = kind === 'folder' ? 'New folder name (under ' + parentDir + '):' : 'New markdown filename (under ' + parentDir + '):'; diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 5c6a92f..6e294ff 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -147,7 +147,7 @@ func TestDispatchAppsResolution(t *testing.T) { } // Create folder convention dirs so classifier/browse/transmittal // availability rules pass for the test paths used below. - mustMkdir(t, filepath.Join(root, "Project-A", "Working")) + mustMkdir(t, filepath.Join(root, "Project-A", "archive", "Acme", "working")) idx, err := archive.BuildIndex(root) if err != nil { @@ -278,7 +278,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, ".zddc"), "acl:\n permissions:\n \"*@example.com\": rwcd\n") - mustMkdir(t, filepath.Join(root, "Project-A", "Working")) + mustMkdir(t, filepath.Join(root, "Project-A", "archive", "Acme", "working")) idx, err := archive.BuildIndex(root) if err != nil { @@ -298,11 +298,11 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { return req.WithContext(handler.WithEmail(req.Context(), email)) } - // PUT a new file via dispatch. Files live in a sub-folder under - // working/ (creator-owned); bare files at the working/ root are - // document-controller-only (see TestFileAPI_WorkingRootFileDocControllerOnly). + // PUT a new file via dispatch. Content is party-scoped under + // archive//working/ — the project-level working/ aggregator is + // virtual (see TestFileAPI_MkdirInAggregatorRejected). body := []byte("note body") - req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/drafts/note.md", strings.NewReader(string(body))), "alice@example.com") + req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/archive/Acme/working/note.md", strings.NewReader(string(body))), "alice@example.com") rec := httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusCreated { @@ -310,7 +310,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } // GET it back. - req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/drafts/note.md", nil), "alice@example.com") + req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/archive/Acme/working/note.md", nil), "alice@example.com") rec = httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusOK || rec.Body.String() != string(body) { @@ -318,9 +318,9 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } // MOVE it. - req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/drafts/note.md", nil), "alice@example.com") + req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/archive/Acme/working/note.md", nil), "alice@example.com") req.Header.Set("X-ZDDC-Op", "move") - req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/drafts/renamed.md") + req.Header.Set("X-ZDDC-Destination", "/Project-A/archive/Acme/working/renamed.md") rec = httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusOK { @@ -328,7 +328,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } // DELETE it. - req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/drafts/renamed.md", nil), "alice@example.com") + req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/archive/Acme/working/renamed.md", nil), "alice@example.com") rec = httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusNoContent { diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index 1581235..e0bc0cb 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -392,17 +392,6 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) { if !authorizeAction(cfg, w, r, abs, cleanURL, action) { return } - // Files placed DIRECTLY at the project-level virtual working/ root are - // reserved for the document controller. Regular users create folders - // under working/ and work inside them; the DC creates files at the - // root or promotes them up from a folder (see serveFileMove). Files in - // sub-folders and the working/.zddc config are unaffected. - if filepath.Base(abs) != ".zddc" && isProjectWorkingRootFile(cfg.Root, abs) && - !zddc.IsRoleMemberAt(cfg.Root, filepath.Dir(abs), "document_controller", EmailFromContext(r)) { - auditFile(r, "put", cleanURL, http.StatusForbidden, 0, nil) - http.Error(w, "Forbidden — files cannot be created directly in working/; create a folder and work inside it. Only the document controller may place files at the working/ root.", http.StatusForbidden) - return - } if !checkIfMatch(w, r, abs) { return } @@ -666,13 +655,6 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) { if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) { return } - // Promoting a file to the project-level working/ root is reserved for - // the document controller, same as a direct create there (serveFilePut). - if filepath.Base(dstAbs) != ".zddc" && isProjectWorkingRootFile(cfg.Root, dstAbs) && - !zddc.IsRoleMemberAt(cfg.Root, filepath.Dir(dstAbs), "document_controller", EmailFromContext(r)) { - http.Error(w, "Forbidden — only the document controller may move files to the working/ root.", http.StatusForbidden) - return - } if !checkIfMatch(w, r, srcAbs) { return } @@ -732,6 +714,15 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) { http.Error(w, why, http.StatusConflict) return } + // Mkdir INSIDE a virtual aggregator (working/staging/reviewing/…) has + // no physical home — the content is party-scoped. 409 with a pointer + // at archive/// rather than silently creating an + // unreachable shadow folder. (Browse's picker targets the archive/ + // path directly, so this is the bypass fallback.) + if rejected, why := rejectProjectAggregatorMkdir(cfg.Root, abs); rejected { + http.Error(w, why, http.StatusConflict) + return + } // Resolve canonical-folder casing on the way in (no side effects). if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil { @@ -828,26 +819,6 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) { // Returns (true, reason) when the request should be 409'd. Returns // (false, "") when the target is at any other depth or carries an // allowed name. -// isProjectWorkingRootFile reports whether abs targets a file sitting -// directly in the project-level virtual working/ aggregator — -// /working/ — as opposed to a file inside a sub-folder -// (/working//, depth 4+) or anywhere else. -// Used to gate file creation/promotion at the working/ root to the -// document controller; everything deeper is ordinary creator-owned -// working space. -func isProjectWorkingRootFile(fsRoot, abs string) bool { - rel, err := filepath.Rel(fsRoot, abs) - if err != nil { - return false - } - rel = filepath.ToSlash(rel) - if strings.HasPrefix(rel, "../") || rel == "." { - return false - } - parts := strings.Split(rel, "/") - return len(parts) == 3 && strings.EqualFold(parts[1], "working") -} - func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) { rel, err := filepath.Rel(fsRoot, abs) if err != nil { @@ -878,6 +849,38 @@ func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) { return true, "Conflict — only archive/ and system-reserved (_/. prefix) folders may be created directly under a project. Files belong inside archive//..." } +// rejectProjectAggregatorMkdir reports whether a mkdir lands INSIDE a +// project-level virtual aggregator — /{ssr,mdl,rsk,working, +// staging,reviewing}/[/...] (depth 3+). Those slots aggregate +// per-party content; a folder created there has no physical home. The +// real location is archive///, so we 409 with a +// message pointing at the party-scoped path. (rejectProjectRootMkdir +// handles the depth-2 case — the aggregator name itself.) The browse +// "New folder" picker resolves the party client-side and targets the +// archive/ path directly, so a user never trips this in normal use; it +// is the clean fallback for a direct/scripted mkdir that bypassed it. +func rejectProjectAggregatorMkdir(fsRoot, abs string) (bool, string) { + rel, err := filepath.Rel(fsRoot, abs) + if err != nil { + return false, "" + } + rel = filepath.ToSlash(rel) + if rel == "." || strings.HasPrefix(rel, "../") { + return false, "" + } + parts := strings.Split(rel, "/") + if len(parts) < 3 { + return false, "" // depth-2 (the slot itself) is rejectProjectRootMkdir's job + } + switch strings.ToLower(parts[1]) { + case "ssr", "mdl", "rsk", "working", "staging", "reviewing": + slot := strings.ToLower(parts[1]) + return true, "Conflict — " + slot + "/ is a project-level virtual aggregator; folders here belong to a party. " + + "Create it under archive//" + slot + "/ — browse's \"New folder\" picker prompts you for the party." + } + return false, "" +} + // auditFile emits a structured log line for each file API operation. // AccessLogMiddleware already logs every request — this adds an // op-tagged line so audit consumers can filter by operation without diff --git a/zddc/internal/handler/fileapi_test.go b/zddc/internal/handler/fileapi_test.go index b8565c8..14c5a70 100644 --- a/zddc/internal/handler/fileapi_test.go +++ b/zddc/internal/handler/fileapi_test.go @@ -721,103 +721,33 @@ func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) { // working//, the project-level pairing no longer maps cleanly. // Tests for the removed behaviour have been deleted.) -// workingRootSetup builds a root that grants the team rwcd and names a -// document controller (the standard role ships empty; a deployment -// populates it). Returns the same do() helper shape as fileAPITestSetup. -func workingRootSetup(t *testing.T) (root string, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder) { - t.Helper() - root = t.TempDir() - rootZddc := "acl:\n permissions:\n \"*@example.com\": rwcd\n" + - "roles:\n document_controller:\n members: [dc@example.com]\n" - if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(rootZddc), 0o644); err != nil { - t.Fatalf("write root .zddc: %v", err) - } - zddc.InvalidateCache(root) - cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", MaxWriteBytes: 1 << 20} - do = func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder { - var req *http.Request - if body != nil { - req = httptest.NewRequest(method, target, bytes.NewReader(body)) - } else { - req = httptest.NewRequest(method, target, nil) +// Mkdir INSIDE a project-level virtual aggregator is 409'd with a +// pointer at the party-scoped path, instead of silently materialising an +// unreachable shadow. The same folder created under archive// +// / succeeds — which is what browse's party picker targets. +func TestFileAPI_MkdirInAggregatorRejected(t *testing.T) { + _, do, root := fileAPITestSetup(t, nil, nil) + + for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} { + rec := do(http.MethodPost, "/Proj/"+slot+"/foo/", "alice@example.com", nil, map[string]string{ + "X-ZDDC-Op": "mkdir", + }) + if rec.Code != http.StatusConflict { + t.Errorf("%s: mkdir in aggregator: want 409, got %d: %s", slot, rec.Code, rec.Body.String()) } - for k, v := range headers { - req.Header.Set(k, v) + if _, err := os.Stat(filepath.Join(root, "Proj", slot)); !os.IsNotExist(err) { + t.Errorf("%s: aggregator slot must not be materialised; got err=%v", slot, err) } - ctx := context.WithValue(req.Context(), EmailKey, email) - ctx = context.WithValue(ctx, ElevatedKey, true) - req = req.WithContext(ctx) - rec := httptest.NewRecorder() - ServeFileAPI(cfg, rec, req) - return rec - } - return root, do -} - -// Files directly at the project-level working/ root are document- -// controller-only; folders (and files inside them) are open to the team -// and become creator-owned. -func TestFileAPI_WorkingRootFileDocControllerOnly(t *testing.T) { - root, do := workingRootSetup(t) - - // Non-DC user: a bare file at the working/ root is forbidden. - if rec := do(http.MethodPut, "/Proj/working/memo.md", "alice@example.com", []byte("x"), nil); rec.Code != http.StatusForbidden { - t.Errorf("non-DC file at working/ root: want 403, got %d: %s", rec.Code, rec.Body.String()) - } - if _, err := os.Stat(filepath.Join(root, "Proj", "working", "memo.md")); !os.IsNotExist(err) { - t.Errorf("forbidden file must not be written") } - // Same user CAN create a folder and work inside it; the folder - // becomes creator-owned (unfenced auto-own .zddc). - if rec := do(http.MethodPut, "/Proj/working/drafts/notes.md", "alice@example.com", []byte("x"), nil); rec.Code != http.StatusCreated { - t.Fatalf("user file inside working folder: want 201, got %d: %s", rec.Code, rec.Body.String()) - } - z, err := os.ReadFile(filepath.Join(root, "Proj", "working", "drafts", ".zddc")) - if err != nil { - t.Fatalf("creator-owned folder auto-own .zddc missing: %v", err) - } - if !strings.Contains(string(z), "alice@example.com: rwcda") { - t.Errorf("creator folder should grant alice rwcda; got: %s", z) - } - - // The document controller CAN place a file at the working/ root. - if rec := do(http.MethodPut, "/Proj/working/memo.md", "dc@example.com", []byte("x"), nil); rec.Code != http.StatusCreated { - t.Errorf("DC file at working/ root: want 201, got %d: %s", rec.Code, rec.Body.String()) - } -} - -// Promoting a file from a working folder up to the working/ root is -// document-controller-only, mirroring direct creation. -func TestFileAPI_WorkingRootMoveDocControllerOnly(t *testing.T) { - root, do := workingRootSetup(t) - - // alice owns a folder with a file. - if rec := do(http.MethodPut, "/Proj/working/drafts/a.md", "alice@example.com", []byte("body"), nil); rec.Code != http.StatusCreated { - t.Fatalf("seed file: want 201, got %d: %s", rec.Code, rec.Body.String()) - } - - // alice cannot promote it to the working/ root. - rec := do(http.MethodPost, "/Proj/working/drafts/a.md", "alice@example.com", nil, map[string]string{ - "X-ZDDC-Op": "move", - "X-ZDDC-Destination": "/Proj/working/a.md", + // The party-scoped path the picker resolves to works. + rec := do(http.MethodPost, "/Proj/archive/Acme/working/drafts/", "alice@example.com", nil, map[string]string{ + "X-ZDDC-Op": "mkdir", }) - if rec.Code != http.StatusForbidden { - t.Errorf("non-DC move to working/ root: want 403, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusCreated { + t.Fatalf("party-scoped mkdir: want 201, got %d: %s", rec.Code, rec.Body.String()) } - if _, err := os.Stat(filepath.Join(root, "Proj", "working", "a.md")); !os.IsNotExist(err) { - t.Errorf("forbidden move must not create the destination") - } - - // The document controller can. - rec = do(http.MethodPost, "/Proj/working/drafts/a.md", "dc@example.com", nil, map[string]string{ - "X-ZDDC-Op": "move", - "X-ZDDC-Destination": "/Proj/working/a.md", - }) - if rec.Code != http.StatusOK { - t.Errorf("DC move to working/ root: want 200, got %d: %s", rec.Code, rec.Body.String()) - } - if _, err := os.Stat(filepath.Join(root, "Proj", "working", "a.md")); err != nil { - t.Errorf("DC move should land the file at the root: %v", err) + if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "Acme", "working", "drafts")); err != nil { + t.Errorf("party-scoped folder not created: %v", err) } } diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 35dac96..cb1b499 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -206,41 +206,13 @@ paths: working: default_tool: browse available_tools: [browse] - # Project-level working is BOTH an outstanding-only aggregator of - # the per-party archive//working/ slots AND the home for - # shared, creator-owned working folders at /working/ - # /. (The earlier per-user / "personal workspace" - # concept was dropped — too much complexity for too little gain. - # A working folder is just a named drafting space its creator - # owns; nothing keys off the email.) + # Pure folder-nav aggregator over the per-party + # archive//working/ slots — same shape as staging/ and + # reviewing/ below. Nothing lives directly at /working/: + # creating a folder here prompts for a party (browse's "New + # folder" picker) and lands it at archive//working/, + # which carries its own history: true + auto-own convention. virtual: true - # Edit-history versioning for markdown drafts (inherits to the - # folders below). archive//working/ carries its own - # history: true separately. - history: true - # Project members may CREATE here (so they can make their own - # working folders) but the file handler additionally restricts - # bare files at this root to the document controller — users put - # files inside a folder they created; the DC promotes/creates - # files at the root. The DC carries rwc so it can do both (and, - # unfenced, that authority cascades into every folder below). - acl: - permissions: - project_team: rc - document_controller: rwc - paths: - "*": - # A creator-owned working folder: /working//. - # auto_own (NOT auto_own_fenced) makes the creator the owner - # (rwcda + a .zddc they can edit) while leaving the folder - # readable to the rest of the project_team via the unfenced - # cascade above. Other members inherit rc here (read + add - # new files) but lack w/d, so they cannot modify or delete - # the owner's files; the owner narrows this in their own - # .zddc if they want it private. - default_tool: browse - available_tools: [browse] - auto_own: true staging: default_tool: browse available_tools: [browse] diff --git a/zddc/internal/zddc/ensure.go b/zddc/internal/zddc/ensure.go index c7bcede..884abef 100644 --- a/zddc/internal/zddc/ensure.go +++ b/zddc/internal/zddc/ensure.go @@ -84,15 +84,10 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) { // // Canonical positions, relative to fsRoot: // -// - /archive (a physical project-root canonical) -// -// - /working (virtual at the slot level, but materialised -// on disk to host creator-owned working folders + document- -// controller files; created as a plain dir, never auto-owned) -// -// staging/reviewing/ssr/mdl/rsk at project root are virtual -// aggregators with no on-disk presence — writes targeting them are -// rejected here and by the caller's project-root mkdir guard. +// - /archive (the only physical project-root canonical; +// working/staging/reviewing/ssr/mdl/rsk at project root are virtual +// aggregators with no on-disk presence — writes targeting them +// must be rejected by the caller's project-root mkdir guard.) // // - /archive// where // ∈ {mdl, rsk, incoming, received, issued, @@ -116,24 +111,15 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil return target, nil } - // Project-root virtual aggregators. ssr/mdl/rsk are pure tables - // rollups and staging/reviewing are synthesised lifecycle windows — - // none materialise on disk; row PUTs / lifecycle writes are rewritten - // to canonical archive// paths by ResolveVirtualView before - // reaching here, so a physical write under one of these means the - // resolver was bypassed. - // - // working/ is the exception: it is virtual at the slot level (the - // listing is synthesised from archive/*/working/), but it is also - // the home for creator-owned working folders at /working/ - // /. The slot dir is instantiated on disk the moment real - // content is created beneath it. Fall through to materialise it + - // the target's ancestors; WHO may write here (users create folders; - // only the document controller places files at the root) is gated in - // the file handler, not here. + // Reject writes targeting top-level virtual aggregators — + // /{ssr,mdl,rsk,working,staging,reviewing}/... — these + // resolve through ResolveVirtualView, not as physical paths. A + // caller writing under them bypassed the virtual resolver; the + // content belongs under archive/// (browse's "New + // folder" picker prompts for the party). if len(parts) >= 2 { switch strings.ToLower(parts[1]) { - case "ssr", "mdl", "rsk", "staging", "reviewing": + case "ssr", "mdl", "rsk", "working", "staging", "reviewing": return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1]) } } diff --git a/zddc/internal/zddc/ensure_test.go b/zddc/internal/zddc/ensure_test.go index 1da4e1d..6891be0 100644 --- a/zddc/internal/zddc/ensure_test.go +++ b/zddc/internal/zddc/ensure_test.go @@ -197,12 +197,12 @@ func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) { } } -// Project-root virtual aggregator names (except working/) are rejected — -// a write targeting /{staging,reviewing,ssr,mdl,rsk}/<...> -// bypasses the virtual resolver and must not materialise on disk. +// Project-root virtual aggregator names are rejected — a write +// targeting /working/<...> bypasses the virtual resolver +// and must not materialise on disk. func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) { root := t.TempDir() - for _, slot := range []string{"staging", "reviewing", "ssr", "mdl", "rsk"} { + for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} { target := filepath.Join(root, "Proj", slot, "x.md") _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755) if err == nil { @@ -214,52 +214,6 @@ func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) { } } -// working/ is the exception: it hosts creator-owned working folders, so -// real content beneath it instantiates the (otherwise virtual) slot dir -// on disk. The slot dir itself stays plain (never auto-owned); the -// creator-owned folder under it gets the unfenced auto-own .zddc. -func TestEnsureCanonicalAncestors_WorkingMaterialisesCreatorFolder(t *testing.T) { - root := t.TempDir() - target := filepath.Join(root, "Proj", "working", "drafts", "notes.md") - if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil { - t.Fatalf("ensure: %v", err) - } - if _, err := os.Stat(filepath.Join(root, "Proj", "working")); err != nil { - t.Errorf("working/ not created: %v", err) - } - if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) { - t.Errorf("working/ slot dir must stay plain (no auto-own .zddc); got err=%v", err) - } - folderZddc := filepath.Join(root, "Proj", "working", "drafts", ".zddc") - data, err := os.ReadFile(folderZddc) - if err != nil { - t.Fatalf("creator folder auto-own .zddc missing: %v", err) - } - if !strings.Contains(string(data), "alice@x.com: rwcda") { - t.Errorf("creator folder auto-own missing creator grant: %s", data) - } - if strings.Contains(string(data), "inherit: false") { - t.Errorf("creator working folder must be UNFENCED (readable by the team); got: %s", data) - } -} - -// A bare file directly at the project-level working/ root still -// materialises the slot dir — the file handler gates WHO may write it, -// not EnsureCanonicalAncestors. -func TestEnsureCanonicalAncestors_WorkingRootFileMaterialisesSlot(t *testing.T) { - root := t.TempDir() - target := filepath.Join(root, "Proj", "working", "memo.md") - if _, err := EnsureCanonicalAncestors(root, target, "dc@x.com", 0o755); err != nil { - t.Fatalf("ensure: %v", err) - } - if _, err := os.Stat(filepath.Join(root, "Proj", "working")); err != nil { - t.Errorf("working/ not created: %v", err) - } - if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) { - t.Errorf("working/ slot dir must stay plain (no auto-own .zddc); got err=%v", err) - } -} - func TestEnsureCanonicalAncestors_RejectsTraversal(t *testing.T) { root := t.TempDir() other := t.TempDir() diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index 23fb2ae..bca0398 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -159,23 +159,6 @@ func HistoryAt(fsRoot, dirPath string) bool { return chain.EffectiveHistory() } -// IsRoleMemberAt reports whether email is a member of roleName as the -// role is visible (after fences / resets, unioned with the embedded -// defaults) at dirPath's cascade leaf. Returns false for an empty -// email or on cascade error. Used by the file handler to gate the few -// operations reserved for a standard role — e.g. only the -// document_controller may place files directly at the working/ root. -func IsRoleMemberAt(fsRoot, dirPath, roleName, email string) bool { - if email == "" { - return false - } - chain, err := EffectivePolicy(fsRoot, dirPath) - if err != nil || len(chain.Levels) == 0 { - return false - } - return MatchesPrincipal(roleName, email, chain, len(chain.Levels)-1) -} - // IsDeclaredPath reports whether dirPath is mentioned in the // cascade — either by an on-disk .zddc at that level OR by any // ancestor's paths: tree (including the embedded defaults). diff --git a/zddc/internal/zddc/lookups_test.go b/zddc/internal/zddc/lookups_test.go index 366e9ab..a32f85f 100644 --- a/zddc/internal/zddc/lookups_test.go +++ b/zddc/internal/zddc/lookups_test.go @@ -49,10 +49,10 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) { } // TestHistoryAt_Defaults — the embedded convention enables edit-history -// versioning on BOTH (a) the project-level personal workspace -// /working/ + its per-user / homes, and (b) the per-party -// archive//working/ + its homes. History is subtree-inheriting and -// ignores the homes' inherit:false fences. Sibling slots (staging, +// versioning on the per-party archive//working/ subtree (+ its +// homes). History is subtree-inheriting and ignores the homes' +// inherit:false fences. The project-level working/ aggregator is virtual +// (no direct content → no history), and sibling slots (staging, // reviewing, mdl, incoming, received) do NOT get history. func TestHistoryAt_Defaults(t *testing.T) { resetCache() @@ -61,11 +61,10 @@ func TestHistoryAt_Defaults(t *testing.T) { path string want bool }{ - // Project-level personal workspace. - {filepath.Join(root, "Project-X", "working"), true}, - {filepath.Join(root, "Project-X", "working", "alice@example.com"), true}, - {filepath.Join(root, "Project-X", "working", "alice@example.com", "notes"), true}, - // Per-party working. + // Project-level working/ is a pure virtual aggregator — no + // direct content, so no history there. + {filepath.Join(root, "Project-X", "working"), false}, + // Per-party working carries history (edit-history versioning). {filepath.Join(root, "Project-X", "archive", "Acme", "working"), true}, {filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true}, {filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes"), true}, From 96262a171e82287d99155dcb35b903a6fadc81eb Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 1 Jun 2026 10:46:51 -0500 Subject: [PATCH 2/2] feat(browse): full create/edit/rename/delete in local-directory (offline) mode Local folders are picked read-only, so create/edit/rename/delete were either disabled or would fail. Now offline mode supports the same CRUD as server mode, bounded only by what the filesystem grants: - upload.js: ensureWritable() escalates the picked root to readwrite via the FS-Access permission prompt on the first mutation (one prompt, then granted for the session; requires the user gesture every caller has). makeDir/makeFile gain FS-API branches (getDirectoryHandle/getFileHandle {create:true} + createWritable) resolved through handleForDir; removeNode and renameNode (already FS-API) now escalate first. - preview-markdown.js: the markdown editor's save escalates before createWritable, so editing a local .md persists. - events.js: New folder / New markdown file menu items are enabled whenever there's a writable target (server, or a picked local folder) via canCreateHere(); rename/delete were already gated by canMutate (FS-API). The aggregator party-picker stays server-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/js/events.js | 16 ++++-- browse/js/preview-markdown.js | 4 ++ browse/js/upload.js | 92 +++++++++++++++++++++++++++-------- 3 files changed, 89 insertions(+), 23 deletions(-) diff --git a/browse/js/events.js b/browse/js/events.js index c2c620e..10952c2 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -695,6 +695,7 @@ // aggregatorRoot + the picker). Covers right-clicking a party row // shown in an aggregator listing so "New folder" doesn't 409. function rewriteAggregatorPath(parentDir) { + if (state.source !== 'server') return null; var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/'); if (segs.length < 3 || !segs[0]) return null; var slot = segs[1].toLowerCase(); @@ -1005,6 +1006,13 @@ // Items are kept VISIBLE but DISABLED when they don't apply, so // every menu has the same shape regardless of what the user // right-clicked. Predictable position = muscle memory. + // canCreateHere — whether New folder/file has a writable target: the + // server (ACL decides the rest) or a picked local folder (the + // filesystem permission decides, escalated on first write). + function canCreateHere() { + return state.source === 'server' || (state.source === 'fs' && !!state.rootHandle); + } + function buildTreeRowMenu(ctx) { var serverMode = state.source === 'server'; var canMutate = function (c) { @@ -1064,12 +1072,12 @@ // ── Create new (in the row's parent folder) ── { label: 'New folder', - disabled: !serverMode, + disabled: !canCreateHere(), action: function (c) { createInside(c.node, 'folder'); } }, { label: 'New markdown file', - disabled: !serverMode, + disabled: !canCreateHere(), action: function (c) { createInside(c.node, 'markdown'); } }, { separator: true }, @@ -1278,12 +1286,12 @@ return [ { label: 'New folder', - disabled: !serverMode, + disabled: !canCreateHere(), action: function () { createInDir(state.currentPath || '/', 'folder'); } }, { label: 'New markdown file', - disabled: !serverMode, + disabled: !canCreateHere(), action: function () { createInDir(state.currentPath || '/', 'markdown'); } }, // ── Create Transmittal folder (staging/ scope only) ── diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index faad30a..b9854b0 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -275,6 +275,10 @@ async function saveContent(node, content) { if (node.handle && typeof node.handle.createWritable === 'function') { + // Local folders are picked read-only; escalate to readwrite on + // first save (one FS-Access prompt, then granted for the session). + var up = window.app.modules.upload; + if (up && up.ensureWritable) await up.ensureWritable(); var writable = await node.handle.createWritable(); await writable.write(content); await writable.close(); diff --git a/browse/js/upload.js b/browse/js/upload.js index fa52c7f..7493788 100644 --- a/browse/js/upload.js +++ b/browse/js/upload.js @@ -303,31 +303,82 @@ } catch (_e) { /* refresh is best-effort */ } } + // ── Write-permission escalation (FS-API mode) ────────────────────────── + // The local folder is picked read-only (showDirectoryPicker mode:read) + // so browsing never prompts. The first mutation escalates to readwrite + // via the FS-Access permission prompt; granting on the picked root + // covers every descendant handle. Must run under a user gesture — every + // caller is reached from a click/menu action. No-op in server mode or + // on browsers without the permission API. + async function ensureWritable() { + if (state.source !== 'fs') return; + var root = state.rootHandle; + if (!root || typeof root.requestPermission !== 'function') return; + var opts = { mode: 'readwrite' }; + if ((await root.queryPermission(opts)) === 'granted') return; + if ((await root.requestPermission(opts)) === 'granted') return; + throw new Error('Write permission denied — grant edit access to the folder when prompted.'); + } + + // handleForDir resolves a directory PATH (FS-API mode) to its + // FileSystemDirectoryHandle: the picked root for the current scope, + // else the matching expanded node's handle. Returns null if unknown. + function handleForDir(dirPath) { + var tree = window.app.modules.tree; + if (!dirPath.endsWith('/')) dirPath += '/'; + if (dirPath === state.currentPath) return state.rootHandle; + var noSlash = dirPath.replace(/\/$/, ''); + var hit = null; + state.nodes.forEach(function (n) { + if (hit || !n.isDir || !n.handle) return; + if (tree && tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n; + }); + return hit ? hit.handle : null; + } + // ── Create-new helpers ──────────────────────────────────────────────── - // Both go through the same server endpoints used by upload: PUT - // for files (with an empty/template body) and POST + X-ZDDC-Op: - // mkdir for directories. Client-side enforcement is best-effort; - // the server's ACL is the source of truth. + // Server mode: PUT for files (empty/template body) and POST + + // X-ZDDC-Op: mkdir for directories; the server's ACL is the source of + // truth. FS-API mode: create directly in the picked tree via + // getDirectoryHandle/getFileHandle({create:true}) + createWritable — + // limited only by the filesystem permission the user granted. async function makeDir(parentDir, name) { - var url = joinUrl(parentDir, name); - if (!url.endsWith('/')) url += '/'; - var resp = await fetch(url, { - method: 'POST', - credentials: 'same-origin', - headers: { 'X-ZDDC-Op': 'mkdir' } - }); - if (!resp.ok) throw new Error('HTTP ' + resp.status); + if (state.source === 'server') { + var url = joinUrl(parentDir, name); + if (!url.endsWith('/')) url += '/'; + var resp = await fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-ZDDC-Op': 'mkdir' } + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return; + } + var parent = handleForDir(parentDir); + if (!parent) throw new Error('No directory handle for ' + parentDir); + await ensureWritable(); + await parent.getDirectoryHandle(name, { create: true }); } async function makeFile(parentDir, name, body, contentType) { - var resp = await fetch(joinUrl(parentDir, name), { - method: 'PUT', - credentials: 'same-origin', - headers: { 'Content-Type': contentType || 'application/octet-stream' }, - body: body == null ? '' : body - }); - if (!resp.ok) throw new Error('HTTP ' + resp.status); + if (state.source === 'server') { + var resp = await fetch(joinUrl(parentDir, name), { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': contentType || 'application/octet-stream' }, + body: body == null ? '' : body + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return; + } + var parent = handleForDir(parentDir); + if (!parent) throw new Error('No directory handle for ' + parentDir); + await ensureWritable(); + var fh = await parent.getFileHandle(name, { create: true }); + var w = await fh.createWritable(); + await w.write(body == null ? '' : body); + await w.close(); } // ── Delete + rename ───────────────────────────────────────────────────── @@ -392,6 +443,7 @@ // legacy removeEntry() lives on the PARENT directory handle // and we don't retain ancestor handles). if (node.handle && typeof node.handle.remove === 'function') { + await ensureWritable(); await node.handle.remove({ recursive: !!node.isDir }); return; } @@ -432,6 +484,7 @@ } // FS-API: handle.move(newName) is Chromium 110+. if (node.handle && typeof node.handle.move === 'function') { + await ensureWritable(); await node.handle.move(newName); return; } @@ -546,6 +599,7 @@ removeNode: removeNode, renameNode: renameNode, canMutate: canMutate, + ensureWritable: ensureWritable, UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES }; })();