feat(zddc): Phase 3 completion — all canonical-folder behaviour now cascade-driven
Final consumer migration. The Go-coded lists that previously encoded
the ZDDC convention all defer to the .zddc cascade now.
Schema added:
available_tools: [tool1, tool2, ...] concat-union across cascade;
tools not in the union are
denied auto-route at that path
auto_own_fenced: true|false generated auto-own .zddc
carries inherit:false (private
to creator)
Lookups added:
AvailableToolsAt(root, dir) union of available_tools across cascade
IsToolAvailableAt(root, dir, tool)
AutoOwnFencedAt(root, dir) leaf-only
Cascade semantics finalised (per field):
default_tool → leaf→root walk (parent applies to descendants)
available_tools → leaf→root union (each level adds; baseline at root)
auto_own → leaf-only (creating THIS dir specifically)
auto_own_fenced → leaf-only (same)
virtual → leaf-only (THIS dir is virtual, not subtree)
Consumers migrated:
apps.DefaultAppAt → zddc.DefaultToolAt
apps.AppAvailableAt → zddc.IsToolAvailableAt (+ landing special)
EnsureCanonicalAncestors → AutoOwnAt + AutoOwnFencedAt
fs.ListDirectory empty-list fallback → zddc.IsDeclaredPath
fs.virtualCanonicalFolders → zddc.ChildrenDeclaredAt
dispatcher canonical-folder branches → unified into one
cascade-declared block
Hardcoded helpers REMOVED (dead code):
apps.inAncestorWithName
zddc.autoOwnDepthMatch / isAutoOwnDepthMatch
Hardcoded lists kept as data sources for the cascade walker but
no longer drive routing logic:
ProjectRootFolders / PartyFolders / AutoOwnCanonicalNames /
VirtualOnlyCanonicalNames / IsProjectRootFolder / IsArchivePartyFolder /
IsArchivePartyMdlDir — all still defined; only `ProjectRootFolders`
is used by special.go's IsProjectRootFolder. The rest are dead.
Dispatcher unified: the previously-two branches (per-party folder vs
project-root folder) collapse into one cascade-declared-path block
that handles the slash/no-slash convention uniformly:
- 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-list fallback
The IsDir branch's switch also un-hardcoded — any cascade tool is
served (not just the legacy 3 names), so e.g. /Project/archive/<party>
/incoming (no slash) now serves classifier directly rather than 302'ing
to the slash form.
defaults.zddc.yaml populated with the canonical convention as the
recipe. Operators edit it (or override per-directory on disk) to
change any behaviour — no Go code changes required.
Browse drag-drop scope (working/staging/incoming) is the one remaining
client-side hardcoded regex; cascading that requires the cascade JSON
to be served to the client, which is its own Phase 4 piece.
Tests updated for the new no-slash mdl URL convention (landing MDL
card test) and no-slash stage URLs (nav strip test). All 248
Playwright + all Go tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9d18047a46
commit
5e393cbeaf
11 changed files with 217 additions and 194 deletions
|
|
@ -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 /<project>/archive/<party>/mdl/ URL.
|
||||
// to the canonical no-slash /<project>/archive/<party>/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 }) => {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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/<party>/{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 <dir>/table.html
|
||||
// and run RecognizeTableRequest; the default-MDL
|
||||
// fallback fires here for archive/<party>/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. <project>/{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:
|
||||
//
|
||||
// <project>/working → mdedit rooted at working/
|
||||
// (matches the existing IsDir branch
|
||||
// for an existing folder)
|
||||
// <project>/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 /<dir>/table.html. Serve the
|
||||
|
|
@ -1003,17 +985,17 @@ 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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Project root (depth-1 dir, no trailing slash) serves the
|
||||
// landing tool, which detects mode='project' from
|
||||
// location.pathname and renders the lifecycle-stage cards +
|
||||
|
|
|
|||
|
|
@ -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 <name>.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/<party>/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,
|
||||
|
|
|
|||
|
|
@ -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/<party>/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/<party>/; 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{
|
||||
|
|
|
|||
|
|
@ -1300,7 +1300,7 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 20:05:10 · ea0d29e-dirty</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 20:31:34 · 9d18047-dirty</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <project>/working/<email>/ 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:
|
||||
// <project>/working or <project>/staging
|
||||
if strings.EqualFold(parentSegs[1], "working") || strings.EqualFold(parentSegs[1], "staging") {
|
||||
return true, false
|
||||
}
|
||||
case 2:
|
||||
// <project>/working/<segment> — 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:
|
||||
// <project>/archive/<party>/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.)
|
||||
|
|
|
|||
|
|
@ -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/<email>/. 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
|
||||
// <dir>/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).
|
||||
|
|
|
|||
|
|
@ -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 <dir>/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 <tool>.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 != "" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue