Compare commits

...

5 commits

Author SHA1 Message Date
28ebaa19cd chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 8s
2026-06-01 13:31:01 -05:00
5ed4f8582b Merge feat/effective-access-hovercard: per-location permissions/roles, copyable read-only YAML, scoped access endpoint
- browse hovercard shows 'Your permissions' (verbs) + 'Your roles' (cascade
  roles) for the hovered folder/file; server adds path_roles to
  /.profile/access?path=.
- read-only YAML/.zddc viewer is selectable/copyable (readOnly:true, not
  'nocursor') without enabling admin mode.
- /.profile/access?path= returns only the path-scoped payload (skips the
  global project + admin-subtree tree walks).
2026-06-01 13:30:46 -05:00
1cf3f3a9b3 perf(server): scope /.profile/access?path= to the requested location only
enumerateAccess always computed the global summary — every project
(EnumerateProjects) and every admin subtree (enumerateAdminSubtrees tree
walk) — and merely appended the path-scoped fields when ?path= was given.
The browse hovercard calls this per folder hovered, so each distinct folder
paid a full global enumeration for data it never reads.

Split the two: a ?path= query now returns ONLY identity + path_verbs/
path_is_admin/path_can_elevate_grant/path_roles and skips the tree walks;
the no-path call still returns the full global view for the profile page.
Verified all path-scoped consumers (browse hovercard, form, tables) read
only path_* fields; the global consumers (elevation, stage, plan-review,
accept-transmittal) all call without ?path=.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:23:22 -05:00
91e6d1ec82 fix(browse): keep read-only YAML/.zddc selectable & copyable
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>
2026-06-01 13:13:20 -05:00
e258b0fa3d feat: show effective permissions + roles per location in the browse hovercard
Hovering a folder/file now shows "Your permissions" (the rwcda verbs you
hold there) and "Your roles" (the cascade roles you're a member of at that
location — e.g. document_controller, project_team). Roles are cascade-
scoped, so they can differ by location; this answers "does the system think
I'm a document_controller here?".

- server: RolesForPrincipalInChain(chain, email) resolves the caller's role
  memberships at a path (honouring fences/resets, incl. embedded standard
  roles); /.profile/access?path= now returns path_roles alongside path_verbs.
- browse hovercard: "Your permissions" from node.verbs (sync); "Your roles"
  async-filled from /.profile/access?path= via zddc.cap.at (memoised).
  Offline mode shows "local folder (filesystem)" and no roles row.

Tests: RolesForPrincipalInChain unit tests (member union, wildcard members,
non-member, fence-hides-ancestor-role, empty email).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:12:39 -05:00
13 changed files with 270 additions and 40 deletions

View file

@ -89,6 +89,24 @@
return 'File';
}
var VERB_NAMES = { r: 'read', w: 'write', c: 'create', d: 'delete', a: 'admin' };
function verbsLabel(verbs) {
return ['r', 'w', 'c', 'd', 'a']
.filter(function (v) { return verbs.indexOf(v) !== -1; })
.map(function (v) { return VERB_NAMES[v]; })
.join(', ');
}
// permsValue renders the per-entry verb set the principal holds here.
// Server mode: node.verbs ("rwcda" subset). Offline (FS-API) mode has
// no ACL — access is whatever the filesystem grants.
function permsValue(verbs) {
if (typeof verbs !== 'string') {
return state.source === 'fs' ? 'local folder (filesystem)' : 'unknown';
}
if (!verbs) return 'none (read-only)';
return verbsLabel(verbs) + ' (' + verbs + ')';
}
function buildRowsHtml(node) {
var tree = window.app.modules.tree;
var z = window.zddc;
@ -147,6 +165,18 @@
if (node.modTime) html += kv('Modified', fmtDate(node.modTime));
if (node.virtual) html += kv('Virtual', 'Not yet created on disk');
// ── Effective access for the current principal at this location ──
// "Your permissions" is the per-entry verb set (sync, from the
// listing). "Your roles" is cascade-scoped — it can differ by
// location — so it needs a path-scoped fetch; render a placeholder
// that fillRoles() updates once /.profile/access?path= resolves.
html += '<div class="tree-hovercard__sep"></div>';
html += kv('Your permissions', permsValue(node.verbs));
if (state.source === 'server') {
html += '<span class="tree-hovercard__key">Your roles</span>'
+ '<span class="tree-hovercard__val" id="hc-roles">…</span>';
}
// Path comes last (longest, most likely to wrap).
var path = tree ? tree.pathFor(node) : '';
if (path) html += kv('Path', path, true);
@ -239,6 +269,25 @@
render(node);
position(row);
card.classList.add('is-visible');
fillRoles(row, node);
}
// Async-fill the "Your roles" row from the path-scoped access view
// (zddc.cap.at memoises per path, so repeat hovers are instant).
// Bails if the card has moved to another row before the fetch lands.
async function fillRoles(row, node) {
if (state.source !== 'server') return;
if (!window.zddc || !window.zddc.cap) return;
var tree = window.app.modules.tree;
var path = tree ? tree.pathFor(node) : '';
if (!path) return;
var view;
try { view = await window.zddc.cap.at(path); } catch (_e) { return; }
if (currentRow !== row) return;
var el = card && card.querySelector('#hc-roles');
if (!el) return;
var roles = (view && Array.isArray(view.path_roles)) ? view.path_roles : [];
el.textContent = roles.length ? roles.join(', ') : 'none';
}
function init() {

View file

@ -484,9 +484,14 @@
// .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,
// Read-only uses readOnly:true (NOT "nocursor"): the editor
// stays focusable so the user can click in, select text, and
// copy — they just can't edit. "nocursor" removes the textarea
// from focus, which also kills click-drag selection (the whole
// reason a viewer would otherwise force admin mode just to copy
// a .zddc snippet). autofocus:false keeps arrow-key tree nav
// intact until the user deliberately clicks into the editor.
readOnly: !writable,
});
// Stash the node on the editor so the lint helper can decide
// whether to apply the .zddc schema layer.

View file

@ -2582,7 +2582,7 @@ td[data-field="trackingNumber"] {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp">v0.0.26</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-01 18:30:53 · 5ed4f85</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

View file

@ -2476,7 +2476,7 @@ body {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp">v0.0.26</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
@ -9511,9 +9511,14 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// .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,
// Read-only uses readOnly:true (NOT "nocursor"): the editor
// stays focusable so the user can click in, select text, and
// copy — they just can't edit. "nocursor" removes the textarea
// from focus, which also kills click-drag selection (the whole
// reason a viewer would otherwise force admin mode just to copy
// a .zddc snippet). autofocus:false keeps arrow-key tree nav
// intact until the user deliberately clicks into the editor.
readOnly: !writable,
});
// Stash the node on the editor so the lint helper can decide
// whether to apply the .zddc schema layer.
@ -9681,6 +9686,24 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
return 'File';
}
var VERB_NAMES = { r: 'read', w: 'write', c: 'create', d: 'delete', a: 'admin' };
function verbsLabel(verbs) {
return ['r', 'w', 'c', 'd', 'a']
.filter(function (v) { return verbs.indexOf(v) !== -1; })
.map(function (v) { return VERB_NAMES[v]; })
.join(', ');
}
// permsValue renders the per-entry verb set the principal holds here.
// Server mode: node.verbs ("rwcda" subset). Offline (FS-API) mode has
// no ACL — access is whatever the filesystem grants.
function permsValue(verbs) {
if (typeof verbs !== 'string') {
return state.source === 'fs' ? 'local folder (filesystem)' : 'unknown';
}
if (!verbs) return 'none (read-only)';
return verbsLabel(verbs) + ' (' + verbs + ')';
}
function buildRowsHtml(node) {
var tree = window.app.modules.tree;
var z = window.zddc;
@ -9739,6 +9762,18 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
if (node.modTime) html += kv('Modified', fmtDate(node.modTime));
if (node.virtual) html += kv('Virtual', 'Not yet created on disk');
// ── Effective access for the current principal at this location ──
// "Your permissions" is the per-entry verb set (sync, from the
// listing). "Your roles" is cascade-scoped — it can differ by
// location — so it needs a path-scoped fetch; render a placeholder
// that fillRoles() updates once /.profile/access?path= resolves.
html += '<div class="tree-hovercard__sep"></div>';
html += kv('Your permissions', permsValue(node.verbs));
if (state.source === 'server') {
html += '<span class="tree-hovercard__key">Your roles</span>'
+ '<span class="tree-hovercard__val" id="hc-roles"></span>';
}
// Path comes last (longest, most likely to wrap).
var path = tree ? tree.pathFor(node) : '';
if (path) html += kv('Path', path, true);
@ -9831,6 +9866,25 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
render(node);
position(row);
card.classList.add('is-visible');
fillRoles(row, node);
}
// Async-fill the "Your roles" row from the path-scoped access view
// (zddc.cap.at memoises per path, so repeat hovers are instant).
// Bails if the card has moved to another row before the fetch lands.
async function fillRoles(row, node) {
if (state.source !== 'server') return;
if (!window.zddc || !window.zddc.cap) return;
var tree = window.app.modules.tree;
var path = tree ? tree.pathFor(node) : '';
if (!path) return;
var view;
try { view = await window.zddc.cap.at(path); } catch (_e) { return; }
if (currentRow !== row) return;
var el = card && card.querySelector('#hc-roles');
if (!el) return;
var roles = (view && Array.isArray(view.path_roles)) ? view.path_roles : [];
el.textContent = roles.length ? roles.join(', ') : 'none';
}
function init() {

View file

@ -1793,7 +1793,7 @@ body.is-elevated::after {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp">v0.0.26</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>

View file

@ -1536,7 +1536,7 @@ body {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC</span>
<span class="build-timestamp">v0.0.26</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85</span></span>
</div>
</div>
<div class="header-right">

View file

@ -2635,7 +2635,7 @@ dialog.modal--narrow {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp">v0.0.26</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-01 18:30:53 · 5ed4f85</span></span>
</div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.26
transmittal=v0.0.26
classifier=v0.0.26
landing=v0.0.26
form=v0.0.26
tables=v0.0.26
browse=v0.0.26
archive=v0.0.27-beta · 2026-06-01 18:30:53 · 5ed4f85
transmittal=v0.0.27-beta · 2026-06-01 18:30:53 · 5ed4f85
classifier=v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85
landing=v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85
form=v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85
tables=v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85
browse=v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85

View file

@ -175,6 +175,12 @@ type AccessView struct {
PathVerbs string `json:"path_verbs,omitempty"`
PathIsAdmin bool `json:"path_is_admin,omitempty"`
PathCanElevateGrant string `json:"path_can_elevate_grant,omitempty"`
// PathRoles is the set of cascade roles the caller belongs to AT
// THIS PATH (e.g. ["document_controller", "project_team"]). Roles
// are cascade-scoped, so this can differ between locations — it's
// the "which roles do I hold here?" answer the browse hovercard
// surfaces. Elevation-independent (role membership, not admin).
PathRoles []string `json:"path_roles,omitempty"`
}
// enumerateAccess builds an AccessView for the given caller. Used by the
@ -191,10 +197,22 @@ type AccessView struct {
// silently ignored (the global fields still return).
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal, pathQuery string) AccessView {
view := AccessView{
Email: p.Email,
EmailHeader: cfg.EmailHeader,
IsSuperAdmin: zddc.IsAdmin(cfg.Root, p),
Email: p.Email,
EmailHeader: cfg.EmailHeader,
}
// Path-scoped query: return ONLY the access for THIS location. The
// global summary (every project, every admin subtree) requires tree
// walks that are irrelevant to "what can I do here?" — and the
// hovercard calls this per folder, so paying that cost per hover
// would be wasteful. Callers that want the global view omit ?path=.
if pathQuery != "" {
populatePathScopedAccess(ctx, decider, cfg, p, pathQuery, &view)
return view
}
// Global summary (the profile page).
view.IsSuperAdmin = zddc.IsAdmin(cfg.Root, p)
view.Projects, _ = EnumerateProjects(ctx, decider, cfg, p)
view.AdminSubtrees = enumerateAdminSubtrees(cfg, p)
view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
@ -210,9 +228,6 @@ func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Con
allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate)
view.CanCreateProject = allowed
}
if pathQuery != "" {
populatePathScopedAccess(ctx, decider, cfg, p, pathQuery, &view)
}
return view
}
@ -241,6 +256,9 @@ func populatePathScopedAccess(ctx context.Context, decider policy.Decider, cfg c
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, p, pathQuery)
view.PathVerbs = verbs.String()
view.PathIsAdmin = p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email)
// Which cascade roles the caller holds at this path — the answer to
// "the system thinks I'm a document_controller here, right?".
view.PathRoles = zddc.RolesForPrincipalInChain(chain, p.Email)
// would_elevate_grant: only meaningful when (a) the caller isn't
// already elevated and (b) elevation would actually change the
// verb set. Avoid noise — an empty value tells the client there
@ -470,17 +488,17 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
allow, _ := policy.AllowFromChain(ctx, decider, chain, probeEmail, probePath)
type levelView struct {
Index int `json:"index"`
ZddcPath string `json:"zddc_path"`
Exists bool `json:"exists"`
Acl *zddc.ACLRules `json:"acl,omitempty"`
Admins []string `json:"admins,omitempty"`
AnyMatch bool `json:"matches_email"`
Decision string `json:"decision_at_level"`
Index int `json:"index"`
ZddcPath string `json:"zddc_path"`
Exists bool `json:"exists"`
Acl *zddc.ACLRules `json:"acl,omitempty"`
Admins []string `json:"admins,omitempty"`
AnyMatch bool `json:"matches_email"`
Decision string `json:"decision_at_level"`
// Inherit is the level's explicit inherit setting if present
// (nil for absent — defaults to "inherit normally"). When
// false, this level fences ancestors above it from descendants.
Inherit *bool `json:"inherit,omitempty"`
Inherit *bool `json:"inherit,omitempty"`
}
// Build the per-level breakdown by walking the chain levels in

View file

@ -540,6 +540,12 @@ acl:
if alice.PathVerbs != "rw" {
t.Errorf("alice PathVerbs = %q, want rw", alice.PathVerbs)
}
// A path-scoped query returns ONLY the access for this location — the
// global summary (projects + admin-subtree walks) is omitted.
if len(alice.Projects) != 0 || len(alice.AdminSubtrees) != 0 || alice.CanCreateProject {
t.Errorf("path-scoped response leaked global fields: Projects=%d AdminSubtrees=%d CanCreateProject=%v",
len(alice.Projects), len(alice.AdminSubtrees), alice.CanCreateProject)
}
if alice.PathIsAdmin {
t.Errorf("alice PathIsAdmin = true, want false")
}

View file

@ -1534,7 +1534,7 @@ body.is-elevated::after {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp">v0.0.26</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-01 18:30:54 · 5ed4f85</span></span>
</div>
</div>
<div class="header-right">

View file

@ -215,3 +215,43 @@ func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string
sort.Strings(out)
return out
}
// RolesForPrincipalInChain returns the sorted, de-duplicated role names
// that email is a member of, as roles resolve at the chain's leaf level —
// honouring inherit:false fences and role resets via MatchesPrincipal.
// Role names declared anywhere in the visible chain OR in the embedded
// defaults are considered (so a standard role like document_controller
// that ships empty but gains members from an on-disk .zddc is reported).
// Returns nil for an empty email or empty chain.
//
// This is "which roles do I hold HERE" — roles are cascade-scoped, so the
// answer can differ between locations. The file handler surfaces it via
// /.profile/access?path=… (AccessView.PathRoles).
func RolesForPrincipalInChain(chain PolicyChain, email string) []string {
if email == "" || len(chain.Levels) == 0 {
return nil
}
leaf := len(chain.Levels) - 1
seen := make(map[string]struct{})
var out []string
consider := func(name string) {
if _, dup := seen[name]; dup {
return
}
seen[name] = struct{}{}
if MatchesPrincipal(name, email, chain, leaf) {
out = append(out, name)
}
}
floor := chain.VisibleStart(leaf)
for i := leaf; i >= floor; i-- {
for name := range chain.Levels[i].Roles {
consider(name)
}
}
for name := range chain.Embedded.Roles {
consider(name)
}
sort.Strings(out)
return out
}

View file

@ -10,7 +10,7 @@ func TestParseVerbSetRoundTrip(t *testing.T) {
{"", ""},
{"r", "r"},
{"rw", "rw"},
{"wr", "rw"}, // canonical reorder
{"wr", "rw"}, // canonical reorder
{"rwcd", "rwcd"},
{"adcwr", "rwcda"}, // canonical reorder
{"RWCDA", "rwcda"}, // case-insensitive
@ -50,12 +50,12 @@ func TestVerbSetHasAndUnion(t *testing.T) {
func TestIsPrincipalRole(t *testing.T) {
cases := map[string]bool{
"alice@example.com": false,
"*@example.com": false,
"alice@*": false,
"_doc_controller": true,
"vendor_acme": true,
"*": true, // legacy bare wildcard — treated as role-or-pattern
"alice@example.com": false,
"*@example.com": false,
"alice@*": false,
"_doc_controller": true,
"vendor_acme": true,
"*": true, // legacy bare wildcard — treated as role-or-pattern
}
for in, want := range cases {
if got := IsPrincipalRole(in); got != want {
@ -163,3 +163,61 @@ func TestMatchesPrincipalRoleNamePrefersRole(t *testing.T) {
t.Errorf("rep@other.com should NOT match role vendor_acme — fallback to pattern would wrongly succeed")
}
}
func sameStrs(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestRolesForPrincipalInChain — "which roles do I hold here?" honours
// member unions across the visible chain, wildcard members, and reports
// nothing for non-members / empty email. Output is sorted.
func TestRolesForPrincipalInChain(t *testing.T) {
chain := buildChain(
ZddcFile{
Roles: map[string]Role{
"document_controller": {Members: []string{"dc@example.com"}},
"project_team": {Members: []string{"*@example.com"}},
},
ACL: aclOpen(map[string]string{"*@example.com": "r"}),
},
// A deeper level adds another DC; the root members still union in.
ZddcFile{
Roles: map[string]Role{
"document_controller": {Members: []string{"vendor-dc@example.com"}},
},
},
)
if got := RolesForPrincipalInChain(chain, "dc@example.com"); !sameStrs(got, []string{"document_controller", "project_team"}) {
t.Errorf("dc: got %v, want [document_controller project_team]", got)
}
if got := RolesForPrincipalInChain(chain, "alice@example.com"); !sameStrs(got, []string{"project_team"}) {
t.Errorf("alice: got %v, want [project_team]", got)
}
if got := RolesForPrincipalInChain(chain, "x@other.com"); len(got) != 0 {
t.Errorf("outsider: got %v, want none", got)
}
if got := RolesForPrincipalInChain(chain, ""); got != nil {
t.Errorf("empty email: got %v, want nil", got)
}
}
// A role defined above an inherit:false fence is invisible below it, so
// membership there reports no such role.
func TestRolesForPrincipalInChain_FenceHidesAncestorRole(t *testing.T) {
chain := buildChain(
ZddcFile{Roles: map[string]Role{"document_controller": {Members: []string{"dc@example.com"}}}},
ZddcFile{ACL: aclFenced(map[string]string{"*@vendor.com": "rwcd"}, false)},
)
if got := RolesForPrincipalInChain(chain, "dc@example.com"); len(got) != 0 {
t.Errorf("role above fence must be invisible below it; got %v", got)
}
}