fix(browse): preserve undefined verbs to distinguish Caddy/FS-API from zddc

Three modes again behave consistently after Part 3's per-entry
gating:

  1. file:// (FS Access API picker) — fromHandle leaves verbs unset
     (now undefined, not ""). The events.js Rename/Delete gates
     skip the cap.has cascade check when typeof node.verbs is not
     'string', so the items stay enabled per the original canMutate
     contract.

  2. Caddy file-server — fromServerEntry sees no verbs in the
     listing and preserves undefined. Same skip applies; Rename /
     Delete stay enabled but the underlying server will 405 the
     POST/DELETE (same pre-Part-3 behavior). Markdown/yaml editors
     still mount read-only via cap.has's writable fallback.

  3. zddc-server — verbs is always emitted (possibly as "" for an
     explicit zero grant). cap.has interprets the string and the
     gates apply.

The previous "verbs ?? ''" normalisation collapsed (1)+(2) into the
explicit-zero case, which incorrectly disabled Rename/Delete in
offline mode. Tri-state verbs (string non-empty / string empty /
undefined) restores the intent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-21 09:00:12 -05:00
parent c87dccdb23
commit 360049f482
3 changed files with 46 additions and 22 deletions

View file

@ -898,22 +898,31 @@
//
// Two gates compose: canMutate() rules out un-writable
// sources (offline FS-API without a handle, zip members,
// virtual placeholders) and zddc.cap.has(node, verb)
// applies the server-computed cascade verbs. If either
// bars the action, the item disables with a tooltip
// explaining the reason — server-side ACL still has the
// final say on the actual PUT/DELETE if a stale client
// somehow tries.
// virtual placeholders) and — when the listing carries
// server-cascade verbs — zddc.cap.has(node, verb) applies
// the per-entry ACL. The verbs gate is server-mode only;
// file:// FS-API and plain Caddy listings have no verbs
// field, so we fall back to canMutate alone (FS-API
// enforces locally; Caddy has no PUT/DELETE either way).
// Server-side ACL still has the final say on the actual
// PUT/DELETE if a stale client tries the action.
{
label: 'Rename…',
disabled: function (c) {
if (!canMutate(c)) return true;
if (!window.zddc.cap) return false;
if (!serverMode || !window.zddc.cap) return false;
// verbs===undefined → Caddy or other non-zddc
// server, no cascade signal to gate on. verbs===""
// is zddc-server's explicit zero grant; still
// gate (disable). verbs==="rw…" → check the bit.
if (typeof c.node.verbs !== 'string') return false;
return !window.zddc.cap.has(c.node, 'w');
},
tooltip: function (c) {
if (!canMutate(c)) return '';
if (!window.zddc.cap || window.zddc.cap.has(c.node, 'w')) return '';
if (!serverMode || !canMutate(c)) return '';
if (!window.zddc.cap) return '';
if (typeof c.node.verbs !== 'string') return '';
if (window.zddc.cap.has(c.node, 'w')) return '';
return "You don't have write access to this item.";
},
action: function (c) { renameNode(c.node); }
@ -924,12 +933,15 @@
danger: true,
disabled: function (c) {
if (!canMutate(c)) return true;
if (!window.zddc.cap) return false;
if (!serverMode || !window.zddc.cap) return false;
if (typeof c.node.verbs !== 'string') return false;
return !window.zddc.cap.has(c.node, 'd');
},
tooltip: function (c) {
if (!canMutate(c)) return '';
if (!window.zddc.cap || window.zddc.cap.has(c.node, 'd')) return '';
if (!serverMode || !canMutate(c)) return '';
if (!window.zddc.cap) return '';
if (typeof c.node.verbs !== 'string') return '';
if (window.zddc.cap.has(c.node, 'd')) return '';
return "You don't have delete access to this item.";
},
action: function (c) { deleteNode(c.node); }

View file

@ -46,10 +46,20 @@
// Server-computed verb set: canonical "rwcda" subset the
// calling principal holds at this entry's URL. Per-entry
// gating in the context menu (Rename/Delete) reads this
// through zddc.cap.has(node, 'w'|'d'). Empty string is the
// explicit-deny case; absence (offline FS-API mode) makes
// zddc.cap.has fall back to the writable bit for 'w'.
verbs: typeof e.verbs === 'string' ? e.verbs : '',
// through zddc.cap.has(node, 'w'|'d').
//
// "rw…" — zddc-server emitted explicit grant.
// "" — zddc-server emitted explicit zero grant
// (rare; usually the entry would have been
// filtered before reaching the client).
// undefined — the server didn't emit a verbs field at
// all (Caddy or any non-zddc backend).
// cap.has and the events.js gates treat
// this as "verbs unknown" and skip the
// per-entry cascade gate; canMutate +
// whatever the server enforces on the
// actual PUT/DELETE still apply.
verbs: typeof e.verbs === 'string' ? e.verbs : undefined,
// FS-API specific (null in server mode):
handle: null
};

View file

@ -51,12 +51,14 @@
// cause behind "I'm admin but the editor says read-only".
writable: !!raw.writable,
// Server-computed verb set (canonical "rwcda" subset).
// Per-entry permission gating in the context menu reads
// this via zddc.cap.has(node, verb). Empty string = no
// verbs known / explicit deny; offline FS-API listings
// leave it empty and gating falls back to the writable
// bit through cap.has's transition shim.
verbs: typeof raw.verbs === 'string' ? raw.verbs : ''
// Per-entry permission gating reads this via
// zddc.cap.has(node, verb). Three states:
// "rw…" — zddc-server explicit grant
// "" — zddc-server explicit zero grant
// undefined — Caddy / FS-API listings (no verbs field).
// Per-entry gates skip the cascade check
// and fall back to canMutate / writable.
verbs: typeof raw.verbs === 'string' ? raw.verbs : undefined
};
state.nodes.set(id, node);
return node;