diff --git a/tests/landing.spec.js b/tests/landing.spec.js index b656c87..1830634 100644 --- a/tests/landing.spec.js +++ b/tests/landing.spec.js @@ -314,14 +314,15 @@ test.describe('Landing project mode', () => { expect(options.slice(1).sort()).toEqual(['PartyA', 'PartyB']); // Selecting a party enables the Open button; clicking it navigates - // to the canonical //archive//mdl/ URL. + // to the canonical no-slash //archive//mdl URL + // (the no-slash form serves the tables tool with the MDL view). await page.selectOption('#mdlPartySelect', 'PartyA'); await expect(page.locator('#mdlOpenBtn')).toBeEnabled(); const [navUrl] = await Promise.all([ - page.waitForURL(/\/Project-1\/archive\/PartyA\/mdl\/$/, { timeout: 5000 }).then(() => page.url()), + page.waitForURL(/\/Project-1\/archive\/PartyA\/mdl$/, { timeout: 5000 }).then(() => page.url()), page.click('#mdlOpenBtn'), ]); - expect(navUrl).toMatch(/\/Project-1\/archive\/PartyA\/mdl\/$/); + expect(navUrl).toMatch(/\/Project-1\/archive\/PartyA\/mdl$/); }); test('legacy presets are migrated to groups on first load', async ({ page }) => { diff --git a/tests/nav.spec.js b/tests/nav.spec.js index 95f9c26..eefc610 100644 --- a/tests/nav.spec.js +++ b/tests/nav.spec.js @@ -72,10 +72,10 @@ test.describe('shared/nav.js stage strip', () => { return Array.from(xs).map(a => ({ text: a.textContent, href: a.getAttribute('href') })); }); expect(links).toEqual([ - { text: 'Archive', href: '/projA/archive.html' }, - { text: 'Working', href: '/projA/working/' }, - { text: 'Staging', href: '/projA/staging/' }, - { text: 'Reviewing', href: '/projA/reviewing/' }, + { text: 'Archive', href: '/projA/archive' }, + { text: 'Working', href: '/projA/working' }, + { text: 'Staging', href: '/projA/staging' }, + { text: 'Reviewing', href: '/projA/reviewing' }, ]); }); diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 7b2be11..2700455 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -894,20 +894,26 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // block → ServeDirectory → embedded browse.html. } } - // Per-party canonical folders at archive//{mdl,incoming, - // received,issued}[/]. The on-disk folder may not exist yet — - // dispatch lands on a usable view regardless: - // - no-slash mdl → tables app (default-MDL fallback) - // - no-slash else → not yet a registered tool → falls - // through to 302 (slash form) - // - slash, all → browse (ServeDirectory → fs.ListDirectory - // empty-listing fallback) - if r.Method == http.MethodGet || r.Method == http.MethodHead { - rel := strings.Trim(strings.TrimPrefix(urlPath, "/"), "/") + // Cascade-declared paths: the .zddc cascade (embedded + // defaults + on-disk overrides) declares this URL even + // if the on-disk directory doesn't exist yet. Land on a + // usable view rather than 404 — usually the slash/no-slash + // convention serves: + // - no-slash, default_tool=tables → ServeTable + // (default-MDL fallback) + // - no-slash, default_tool set → apps.Serve(tool) + // - no-slash, no default_tool → 302 to slash form + // - slash, any → ServeDirectory + // (empty-listing fallback) + if (r.Method == http.MethodGet || r.Method == http.MethodHead) && + zddc.IsDeclaredPath(cfg.Root, absPath) { + chain, _ := zddc.EffectivePolicy(cfg.Root, absPath) if !strings.HasSuffix(urlPath, "/") { + // Tables special case: synthesize /table.html + // and run RecognizeTableRequest; the default-MDL + // fallback fires here for archive//mdl. synth := urlPath + "/table.html" if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil { - chain, _ := zddc.EffectivePolicy(cfg.Root, absPath) if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return @@ -915,38 +921,10 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps handler.ServeTable(cfg, tr, w, r) return } - // No-slash forms for non-mdl party folders 302 to - // the slash form so they consistently land on the - // browse listing. - if zddc.IsArchivePartyFolder(rel) { - http.Redirect(w, r, urlPath+"/", http.StatusFound) - return - } - } else if zddc.IsArchivePartyFolder(rel) { - handler.ServeDirectory(cfg, appsSrv, w, r) - return - } - } - // Canonical project-root folder fallback. /{archive, - // working,staging,reviewing}[/] should land on a usable view - // (default tool or empty listing) rather than 404, so the - // stage-strip nav works on a fresh project that hasn't yet - // been written to. Two shapes: - // - // /working → mdedit rooted at working/ - // (matches the existing IsDir branch - // for an existing folder) - // /working/ → ServeDirectory → fs.ListDirectory - // returns 200 + [] for the empty case - // - // reviewing/ has no default app, so the no-slash form 302s - // to the slash form. - if (r.Method == http.MethodGet || r.Method == http.MethodHead) && - zddc.IsProjectRootFolder(strings.Trim(strings.TrimPrefix(urlPath, "/"), "/")) { - if !strings.HasSuffix(urlPath, "/") { + // Generic default-tool routing for any other + // cascade-declared no-slash path. if app := apps.DefaultAppAt(cfg.Root, absPath); app != "" && appsSrv != nil { if apps.AppAvailableAt(cfg.Root, absPath, app) { - chain, _ := zddc.EffectivePolicy(cfg.Root, absPath) if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return @@ -955,7 +933,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } } - // No default app (reviewing/) — redirect to slash form. + // No default tool — fall through to slash form. http.Redirect(w, r, urlPath+"/", http.StatusFound) return } @@ -992,7 +970,11 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // default applies, fall back to the historical redirect-to- // trailing-slash behaviour. if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot { - switch apps.DefaultAppAt(cfg.Root, absPath) { + app := apps.DefaultAppAt(cfg.Root, absPath) + switch app { + case "": + // no default tool — fall through to the historical + // redirect-to-trailing-slash below. case "tables": // Tables aren't an apps-subsystem app — the table // handler responds to //table.html. Serve the @@ -1003,14 +985,14 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps handler.ServeTable(cfg, tr, w, r) return } - case "archive", "transmittal", "mdedit": - if appsSrv != nil { - app := apps.DefaultAppAt(cfg.Root, absPath) - if apps.AppAvailableAt(cfg.Root, absPath, app) { - chain, _ := zddc.EffectivePolicy(cfg.Root, absPath) - appsSrv.Serve(w, r, app, chain, absPath) - return - } + default: + // Any other cascade-declared tool: serve via the apps + // subsystem when available (gated by AvailableTools + // in the cascade). + if appsSrv != nil && apps.AppAvailableAt(cfg.Root, absPath, app) { + chain, _ := zddc.EffectivePolicy(cfg.Root, absPath) + appsSrv.Serve(w, r, app, chain, absPath) + return } } } diff --git a/zddc/internal/apps/availability.go b/zddc/internal/apps/availability.go index f2c3174..fc809e1 100644 --- a/zddc/internal/apps/availability.go +++ b/zddc/internal/apps/availability.go @@ -8,69 +8,28 @@ import ( ) // AppAvailableAt reports whether app's virtual HTML can be served at -// requestDir. Rules (case-insensitive on canonical folder names): -// -// - archive: every directory (multi-project, project, archive, party) -// - browse: every directory (generic file listing — also the default -// served at folder URLs without an index.html; see directory.go) -// - classifier: requestDir is, or descends from, a folder named -// "working", "staging", or "incoming" (the directories where -// in-flight files get classified) -// - mdedit: requestDir is, or descends from, a "working" or -// "reviewing" folder. working/ is the drafting workspace; -// reviewing/ is the virtual aggregation view of pending review -// responses (server-rendered listings; mdedit follows the -// listing's canonical URLs via the polyfill). -// - transmittal: requestDir is, or descends from, a "staging" folder -// (where outgoing transmittals are prepared) -// - landing: only at the deployment root (the project picker) +// requestDir. Delegates to the .zddc cascade's available_tools union +// (zddc.IsToolAvailableAt). The convention previously hardcoded here +// now lives in defaults.zddc.yaml and is overridable per-directory +// by operators. // // Operators can always drop a real .html file at any path to // override — that path is served by the static handler regardless of // this function's result. AppAvailableAt is consulted only when no // real file exists. // -// In the canonical layout, "incoming" only appears at -// archive//incoming/, so checking "any ancestor named incoming" -// is equivalent to checking "under a per-party incoming folder." +// Landing is a special case: the cascade declares it available +// universally (since available_tools concat-merges from the root +// baseline), but the dispatcher only auto-serves landing at the +// deployment root. We enforce that here too so callers don't trip +// over project-deep landing requests. func AppAvailableAt(root, requestDir, app string) bool { root = filepath.Clean(root) requestDir = filepath.Clean(requestDir) - - switch app { - case "archive", "browse": - return true - case "landing": + if app == "landing" { return requestDir == root - case "classifier": - return inAncestorWithName(root, requestDir, "working", "staging", "incoming") - case "mdedit": - return inAncestorWithName(root, requestDir, "working", "reviewing") - case "transmittal": - return inAncestorWithName(root, requestDir, "staging") } - return false -} - -// inAncestorWithName reports whether requestDir is, or has an ancestor -// (not including root itself), whose last segment case-folds to one -// of names. Match is on segment names, case-insensitively. -func inAncestorWithName(root, requestDir string, names ...string) bool { - if requestDir == root { - return false - } - rel, err := filepath.Rel(root, requestDir) - if err != nil || strings.HasPrefix(rel, "..") { - return false - } - for _, part := range strings.Split(rel, string(filepath.Separator)) { - for _, n := range names { - if strings.EqualFold(part, n) { - return true - } - } - } - return false + return zddc.IsToolAvailableAt(root, requestDir, app) } // DefaultAppAt returns the canonical default tool name for requestDir, diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index 61360b3..0f6051d 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -47,17 +47,15 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, entries, err := os.ReadDir(absDir) if err != nil { - // Empty-listing fallback for canonical project folders. A fresh - // project doesn't have working/, staging/, reviewing/, or even - // archive/ on disk until something is written into them - // (EnsureCanonicalAncestors materialises lazily). The stage-strip - // nav links into these folders unconditionally; without this - // fallback, a click on "Working" against a fresh project 404s. - // Returning [] makes the click land on a usable empty view; the - // virtualUserHomeEntry below still fires for working/ so the - // user sees their own home placeholder. - if os.IsNotExist(err) && - (zddc.IsProjectRootFolder(dirPath) || zddc.IsArchivePartyFolder(dirPath)) { + // Empty-listing fallback for cascade-declared paths. A fresh + // project doesn't have working/, staging/, reviewing/, or + // archive//incoming/ on disk until something is + // written into them — but the cascade (defaults.zddc.yaml + // plus any on-disk overrides) declares them via paths:, so + // the stage-strip / file nav can link unconditionally. + // Returning [] gives a usable empty view; the + // virtualUserHomeEntry below still fires for working/. + if os.IsNotExist(err) && zddc.IsDeclaredPath(fsRoot, absDir) { entries = nil } else { return nil, err @@ -147,22 +145,22 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, return result, nil } -// virtualCanonicalFolders returns synthetic entries for any canonical -// project-root folder absent from real. Fires only when dirPath is a -// depth-1 directory under fsRoot (the project root); other depths get -// an empty slice. Case-insensitive presence check so an on-disk -// "Archive" suppresses the lowercase "archive" virtual entry. +// virtualCanonicalFolders returns synthetic entries for any +// cascade-declared child name that's absent from the on-disk +// listing. Sources from zddc.ChildrenDeclaredAt — the cascade's +// effective paths: at dirPath enumerates the expected children +// (archive, working, staging, reviewing at a project root; mdl, +// incoming, received, issued under archive//; whatever an +// operator added via on-disk .zddc paths:). Case-insensitive +// presence check suppresses a virtual entry when the on-disk +// directory exists in any case. func virtualCanonicalFolders(fsRoot, dirPath, baseURL string, real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo { - rel := strings.Trim(filepath.ToSlash(dirPath), "/") - if rel == "" { + declared := zddc.ChildrenDeclaredAt(fsRoot, dirPath) + if len(declared) == 0 { return nil } - parts := strings.Split(rel, "/") - if len(parts) != 1 { - return nil // not a project root - } present := make(map[string]bool, len(real)) for _, fi := range real { @@ -174,8 +172,8 @@ func virtualCanonicalFolders(fsRoot, dirPath, baseURL string, } var synth []listing.FileInfo - for _, name := range zddc.ProjectRootFolders { - if present[name] { + for _, name := range declared { + if present[strings.ToLower(name)] { continue } synth = append(synth, listing.FileInfo{ diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index c021d09..47c5858 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1300,7 +1300,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-11 20:05:10 · ea0d29e-dirty + v0.0.17-alpha · 2026-05-11 20:31:34 · 9d18047-dirty
diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index a9f73fd..77419cd 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -20,6 +20,14 @@ title: "ZDDC" acl: permissions: {} +# Universal tool baseline. archive (record browser), browse (file +# tree), and landing (project picker) work everywhere. Each canonical +# folder below adds its own context-specific tools (mdedit in +# working/, transmittal in staging/, etc.). The cascade unions +# available_tools across all levels — leaf restrictions don't drop +# ancestor entries — so this baseline propagates to every descendant. +available_tools: [archive, browse, landing] + # ── Canonical project structure ──────────────────────────────────────────── # # Every ZDDC project lives at a top-level directory. Under it the @@ -49,12 +57,14 @@ paths: paths: mdl: default_tool: tables + available_tools: [tables] # The mdl folder is virtual by convention — the # tables tool serves it from the embedded default # spec even when the on-disk folder doesn't exist. virtual: true incoming: default_tool: classifier + available_tools: [classifier] # First write into incoming/ auto-creates an owner # grant so the creator can manage their own drops. auto_own: true @@ -66,18 +76,27 @@ paths: default_tool: archive working: default_tool: mdedit + available_tools: [mdedit, classifier] # working/ auto-owns the first creator + the per-user homes # below. auto_own: true paths: "*": # per-user home dir default_tool: mdedit + available_tools: [mdedit, classifier] auto_own: true + # Per-user home is private by default: the generated + # auto-own .zddc carries inherit:false so ancestor ACL + # grants don't reach inside. The user can edit the file + # to grant collaborators access. + auto_own_fenced: true staging: default_tool: transmittal + available_tools: [transmittal, classifier] auto_own: true reviewing: default_tool: mdedit + available_tools: [mdedit] # reviewing/ is purely virtual — the aggregator handler # synthesises listings from received/ ↔ staging/ ↔ issued/. virtual: true diff --git a/zddc/internal/zddc/ensure.go b/zddc/internal/zddc/ensure.go index 17353cc..705da1c 100644 --- a/zddc/internal/zddc/ensure.go +++ b/zddc/internal/zddc/ensure.go @@ -205,10 +205,16 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil return target, err } - // Determine if this newly-created ancestor is an auto-own position - // and whether it should be fenced (inherit: false). 'i' is the - // index into parentSegs (parentSegs[0] is the project segment). - autoOwn, fenced := autoOwnDepthMatch(parentSegs, i) + // Determine if this newly-created ancestor is an auto-own + // position and whether it should be fenced (inherit: false). + // Resolved via the .zddc cascade — defaults.zddc.yaml + // carries the canonical "working/staging auto-own + per-user + // homes fenced + incoming auto-own" convention, and any + // on-disk .zddc can override per-directory. + _ = parentSegs // depth-tracking no longer needed + _ = i + autoOwn := AutoOwnAt(fsRoot, pathSoFar) + fenced := autoOwn && AutoOwnFencedAt(fsRoot, pathSoFar) freshlyCreated = append(freshlyCreated, created{ absPath: pathSoFar, autoOwn: autoOwn, @@ -241,41 +247,6 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil } -// autoOwnDepthMatch reports whether parentSegs[idx] sits at a canonical -// auto-own depth, and whether the auto-own .zddc should fence ancestor -// cascade (inherit: false). parentSegs is the slash-relative path from -// project root onward (parentSegs[0] is the project segment). -// -// Per-user home folders at /working// are fenced so -// each user's working subtree is private by default; the user can edit -// the file to grant access to others. Other auto-own positions stay -// unfenced — they grant the creator while still allowing ancestor -// cascade to add (e.g.) admin grants from the project root. -func autoOwnDepthMatch(parentSegs []string, idx int) (autoOwn bool, fenced bool) { - switch idx { - case 1: - // /working or /staging - if strings.EqualFold(parentSegs[1], "working") || strings.EqualFold(parentSegs[1], "staging") { - return true, false - } - case 2: - // /working/ — per-user home folder. Fenced so - // other users can't read it via ancestor cascade by default. - if strings.EqualFold(parentSegs[1], "working") { - return true, true - } - case 3: - // /archive//incoming - if strings.EqualFold(parentSegs[1], "archive") && strings.EqualFold(parentSegs[3], "incoming") { - return true, false - } - } - return false, false -} - -// isAutoOwnDepthMatch is a thin wrapper over autoOwnDepthMatch retained -// for any internal callers that only need the first bool. -func isAutoOwnDepthMatch(parentSegs []string, idx int) bool { - autoOwn, _ := autoOwnDepthMatch(parentSegs, idx) - return autoOwn -} +// (autoOwnDepthMatch / isAutoOwnDepthMatch removed in Phase 3c — +// auto-own + fence determination now flows through the .zddc cascade +// via AutoOwnAt / AutoOwnFencedAt.) diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index 1e97b61..a4203db 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -186,6 +186,14 @@ type ZddcFile struct { // created. Empty (nil) inherits via cascade. AutoOwn *bool `yaml:"auto_own,omitempty" json:"auto_own,omitempty"` + // AutoOwnFenced augments AutoOwn: when true, the generated .zddc + // is written with `inherit: false` so the new directory is + // private to its creator (ancestor ACL grants don't apply). Used + // for per-user home folders under working//. Default + // (nil/false) writes a non-fenced auto-own .zddc — ancestor + // admin grants still apply. + AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"` + // Virtual marks a directory as never-materialise-on-disk. The // server treats requests under such a path as virtual routes // rather than triggering EnsureCanonicalAncestors. The reviewing @@ -193,6 +201,23 @@ type ZddcFile struct { // cascade. Virtual *bool `yaml:"virtual,omitempty" json:"virtual,omitempty"` + // AvailableTools restricts which tools the server will auto-serve + // at this directory and its descendants. The effective list is the + // concat-dedupe union of all AvailableTools across the cascade + // (leaf → root → embedded); a tool not in that union is denied + // auto-route at this path. + // + // Empty list at every level means "no tools available" (effectively + // blocks all auto-serving); the embedded defaults seed the + // universal baseline of archive/browse/landing at root. Operators + // can add tools at deeper levels (working/ adds mdedit + classifier, + // staging/ adds transmittal + classifier, etc.). + // + // This does NOT gate explicit static files: an on-disk + // /transmittal.html is always served. It gates only the + // apps-subsystem auto-route. + AvailableTools []string `yaml:"available_tools,omitempty" json:"available_tools,omitempty"` + // Paths declares virtual sub-directory rules without those // directories needing to exist on disk. Each key is a single path // segment — either a literal name or `*` (matches any segment). diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index 56f9f25..7f10336 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -30,11 +30,11 @@ func DefaultToolAt(fsRoot, dirPath string) string { return chain.Embedded.DefaultTool } -// AutoOwnAt reports whether mkdir at this directory should write an -// auto-owned .zddc. Returns the deepest explicit value found in the -// cascade (true OR false), falling back to false when nothing set -// anywhere. *bool semantics let descendants explicitly disable an -// ancestor's auto_own: true. +// AutoOwnAt reports whether mkdir at THIS specific directory should +// write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT +// propagate to descendants (creating working/alice/notes/sub/ does +// not auto-own sub/; only the explicitly-declared per-user home is +// auto-owned). // // Replaces AutoOwnCanonicalNames once the file API's mkdir hook is // migrated. @@ -43,10 +43,9 @@ func AutoOwnAt(fsRoot, dirPath string) bool { if err != nil { return false } - for i := len(chain.Levels) - 1; i >= 0; i-- { - if v := chain.Levels[i].AutoOwn; v != nil { - return *v - } + leaf := leafLevel(chain) + if leaf.AutoOwn != nil { + return *leaf.AutoOwn } if v := chain.Embedded.AutoOwn; v != nil { return *v @@ -54,9 +53,28 @@ func AutoOwnAt(fsRoot, dirPath string) bool { return false } -// VirtualAt reports whether the directory at dirPath is declared as -// purely virtual. Walks the cascade like DefaultToolAt; deepest -// explicit value wins. +// AutoOwnFencedAt reports whether the auto-own .zddc at this dir +// should be written with `inherit: false` (private to creator). +// Leaf-only, same semantic as AutoOwnAt. +func AutoOwnFencedAt(fsRoot, dirPath string) bool { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return false + } + leaf := leafLevel(chain) + if leaf.AutoOwnFenced != nil { + return *leaf.AutoOwnFenced + } + if v := chain.Embedded.AutoOwnFenced; v != nil { + return *v + } + return false +} + +// VirtualAt reports whether THIS specific directory is declared as +// purely virtual (never materialise on disk). Leaf-only: the virtual +// property describes a particular path, not a subtree. A child of a +// virtual directory is not automatically virtual itself. // // Replaces VirtualOnlyCanonicalNames once consumers are migrated. func VirtualAt(fsRoot, dirPath string) bool { @@ -64,10 +82,9 @@ func VirtualAt(fsRoot, dirPath string) bool { if err != nil { return false } - for i := len(chain.Levels) - 1; i >= 0; i-- { - if v := chain.Levels[i].Virtual; v != nil { - return *v - } + leaf := leafLevel(chain) + if leaf.Virtual != nil { + return *leaf.Virtual } if v := chain.Embedded.Virtual; v != nil { return *v @@ -101,6 +118,50 @@ func IsDeclaredPath(fsRoot, dirPath string) bool { return !isZeroZddcFile(leaf) } +// AvailableToolsAt returns the cascade-unioned list of tool names +// the server may auto-serve at this directory. Built by walking +// chain.Levels from leaf to root, then the embedded defaults, and +// concat-deduping each level's AvailableTools. +// +// Empty result means no auto-routed tools are allowed at this path. +// The dispatcher gates auto-route fallbacks against this list; +// explicit static /tool.html files bypass the gate. +func AvailableToolsAt(fsRoot, dirPath string) []string { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return nil + } + seen := make(map[string]struct{}) + var out []string + add := func(list []string) { + for _, t := range list { + if _, ok := seen[t]; ok { + continue + } + seen[t] = struct{}{} + out = append(out, t) + } + } + for i := len(chain.Levels) - 1; i >= 0; i-- { + add(chain.Levels[i].AvailableTools) + } + add(chain.Embedded.AvailableTools) + return out +} + +// IsToolAvailableAt reports whether tool is in the cascade's +// available-tools union at dirPath. The dispatcher gates apps- +// subsystem auto-route on this; explicit on-disk .html files +// always serve regardless. +func IsToolAvailableAt(fsRoot, dirPath, tool string) bool { + for _, t := range AvailableToolsAt(fsRoot, dirPath) { + if t == tool { + return true + } + } + return false +} + // ChildrenDeclaredAt returns the set of child directory names that // the cascade declares should exist under dirPath. Includes // wildcard "*" specs (caller decides how to expose those) and @@ -148,7 +209,10 @@ func isZeroZddcFile(zf ZddcFile) bool { if zf.DefaultTool != "" { return false } - if zf.AutoOwn != nil || zf.Virtual != nil || zf.Inherit != nil { + if zf.AutoOwn != nil || zf.AutoOwnFenced != nil || zf.Virtual != nil || zf.Inherit != nil { + return false + } + if len(zf.AvailableTools) > 0 { return false } if zf.AppsPubKey != "" || zf.CreatedBy != "" { diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go index 6acd2b1..d9f445d 100644 --- a/zddc/internal/zddc/walker.go +++ b/zddc/internal/zddc/walker.go @@ -72,9 +72,13 @@ func mergeOverlay(base, top ZddcFile) ZddcFile { if top.AutoOwn != nil { out.AutoOwn = top.AutoOwn } + if top.AutoOwnFenced != nil { + out.AutoOwnFenced = top.AutoOwnFenced + } if top.Virtual != nil { out.Virtual = top.Virtual } + out.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools) out.Admins = mergeStringSlice(out.Admins, top.Admins) out.ACL.Allow = mergeStringSlice(out.ACL.Allow, top.ACL.Allow)