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']);
|
expect(options.slice(1).sort()).toEqual(['PartyA', 'PartyB']);
|
||||||
|
|
||||||
// Selecting a party enables the Open button; clicking it navigates
|
// 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 page.selectOption('#mdlPartySelect', 'PartyA');
|
||||||
await expect(page.locator('#mdlOpenBtn')).toBeEnabled();
|
await expect(page.locator('#mdlOpenBtn')).toBeEnabled();
|
||||||
const [navUrl] = await Promise.all([
|
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'),
|
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 }) => {
|
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') }));
|
return Array.from(xs).map(a => ({ text: a.textContent, href: a.getAttribute('href') }));
|
||||||
});
|
});
|
||||||
expect(links).toEqual([
|
expect(links).toEqual([
|
||||||
{ text: 'Archive', href: '/projA/archive.html' },
|
{ text: 'Archive', href: '/projA/archive' },
|
||||||
{ text: 'Working', href: '/projA/working/' },
|
{ text: 'Working', href: '/projA/working' },
|
||||||
{ text: 'Staging', href: '/projA/staging/' },
|
{ text: 'Staging', href: '/projA/staging' },
|
||||||
{ text: 'Reviewing', href: '/projA/reviewing/' },
|
{ 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.
|
// block → ServeDirectory → embedded browse.html.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Per-party canonical folders at archive/<party>/{mdl,incoming,
|
// Cascade-declared paths: the .zddc cascade (embedded
|
||||||
// received,issued}[/]. The on-disk folder may not exist yet —
|
// defaults + on-disk overrides) declares this URL even
|
||||||
// dispatch lands on a usable view regardless:
|
// if the on-disk directory doesn't exist yet. Land on a
|
||||||
// - no-slash mdl → tables app (default-MDL fallback)
|
// usable view rather than 404 — usually the slash/no-slash
|
||||||
// - no-slash else → not yet a registered tool → falls
|
// convention serves:
|
||||||
// through to 302 (slash form)
|
// - no-slash, default_tool=tables → ServeTable
|
||||||
// - slash, all → browse (ServeDirectory → fs.ListDirectory
|
// (default-MDL fallback)
|
||||||
// empty-listing fallback)
|
// - no-slash, default_tool set → apps.Serve(tool)
|
||||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
// - no-slash, no default_tool → 302 to slash form
|
||||||
rel := strings.Trim(strings.TrimPrefix(urlPath, "/"), "/")
|
// - 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, "/") {
|
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"
|
synth := urlPath + "/table.html"
|
||||||
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil {
|
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 {
|
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
|
|
@ -915,38 +921,10 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
handler.ServeTable(cfg, tr, w, r)
|
handler.ServeTable(cfg, tr, w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// No-slash forms for non-mdl party folders 302 to
|
// Generic default-tool routing for any other
|
||||||
// the slash form so they consistently land on the
|
// cascade-declared no-slash path.
|
||||||
// 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, "/") {
|
|
||||||
if app := apps.DefaultAppAt(cfg.Root, absPath); app != "" && appsSrv != nil {
|
if app := apps.DefaultAppAt(cfg.Root, absPath); app != "" && appsSrv != nil {
|
||||||
if apps.AppAvailableAt(cfg.Root, absPath, app) {
|
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 {
|
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
|
|
@ -955,7 +933,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No default app (reviewing/) — redirect to slash form.
|
// No default tool — fall through to slash form.
|
||||||
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
||||||
return
|
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-
|
// default applies, fall back to the historical redirect-to-
|
||||||
// trailing-slash behaviour.
|
// trailing-slash behaviour.
|
||||||
if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot {
|
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":
|
case "tables":
|
||||||
// Tables aren't an apps-subsystem app — the table
|
// Tables aren't an apps-subsystem app — the table
|
||||||
// handler responds to /<dir>/table.html. Serve the
|
// 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)
|
handler.ServeTable(cfg, tr, w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "archive", "transmittal", "mdedit":
|
default:
|
||||||
if appsSrv != nil {
|
// Any other cascade-declared tool: serve via the apps
|
||||||
app := apps.DefaultAppAt(cfg.Root, absPath)
|
// subsystem when available (gated by AvailableTools
|
||||||
if apps.AppAvailableAt(cfg.Root, absPath, app) {
|
// in the cascade).
|
||||||
|
if appsSrv != nil && apps.AppAvailableAt(cfg.Root, absPath, app) {
|
||||||
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
|
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
|
||||||
appsSrv.Serve(w, r, app, chain, absPath)
|
appsSrv.Serve(w, r, app, chain, absPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Project root (depth-1 dir, no trailing slash) serves the
|
// Project root (depth-1 dir, no trailing slash) serves the
|
||||||
// landing tool, which detects mode='project' from
|
// landing tool, which detects mode='project' from
|
||||||
// location.pathname and renders the lifecycle-stage cards +
|
// location.pathname and renders the lifecycle-stage cards +
|
||||||
|
|
|
||||||
|
|
@ -8,69 +8,28 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppAvailableAt reports whether app's virtual HTML can be served at
|
// AppAvailableAt reports whether app's virtual HTML can be served at
|
||||||
// requestDir. Rules (case-insensitive on canonical folder names):
|
// requestDir. Delegates to the .zddc cascade's available_tools union
|
||||||
//
|
// (zddc.IsToolAvailableAt). The convention previously hardcoded here
|
||||||
// - archive: every directory (multi-project, project, archive, party)
|
// now lives in defaults.zddc.yaml and is overridable per-directory
|
||||||
// - browse: every directory (generic file listing — also the default
|
// by operators.
|
||||||
// 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)
|
|
||||||
//
|
//
|
||||||
// Operators can always drop a real <name>.html file at any path to
|
// Operators can always drop a real <name>.html file at any path to
|
||||||
// override — that path is served by the static handler regardless of
|
// override — that path is served by the static handler regardless of
|
||||||
// this function's result. AppAvailableAt is consulted only when no
|
// this function's result. AppAvailableAt is consulted only when no
|
||||||
// real file exists.
|
// real file exists.
|
||||||
//
|
//
|
||||||
// In the canonical layout, "incoming" only appears at
|
// Landing is a special case: the cascade declares it available
|
||||||
// archive/<party>/incoming/, so checking "any ancestor named incoming"
|
// universally (since available_tools concat-merges from the root
|
||||||
// is equivalent to checking "under a per-party incoming folder."
|
// 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 {
|
func AppAvailableAt(root, requestDir, app string) bool {
|
||||||
root = filepath.Clean(root)
|
root = filepath.Clean(root)
|
||||||
requestDir = filepath.Clean(requestDir)
|
requestDir = filepath.Clean(requestDir)
|
||||||
|
if app == "landing" {
|
||||||
switch app {
|
|
||||||
case "archive", "browse":
|
|
||||||
return true
|
|
||||||
case "landing":
|
|
||||||
return requestDir == root
|
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
|
return zddc.IsToolAvailableAt(root, requestDir, app)
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultAppAt returns the canonical default tool name for requestDir,
|
// 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)
|
entries, err := os.ReadDir(absDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Empty-listing fallback for canonical project folders. A fresh
|
// Empty-listing fallback for cascade-declared paths. A fresh
|
||||||
// project doesn't have working/, staging/, reviewing/, or even
|
// project doesn't have working/, staging/, reviewing/, or
|
||||||
// archive/ on disk until something is written into them
|
// archive/<party>/incoming/ on disk until something is
|
||||||
// (EnsureCanonicalAncestors materialises lazily). The stage-strip
|
// written into them — but the cascade (defaults.zddc.yaml
|
||||||
// nav links into these folders unconditionally; without this
|
// plus any on-disk overrides) declares them via paths:, so
|
||||||
// fallback, a click on "Working" against a fresh project 404s.
|
// the stage-strip / file nav can link unconditionally.
|
||||||
// Returning [] makes the click land on a usable empty view; the
|
// Returning [] gives a usable empty view; the
|
||||||
// virtualUserHomeEntry below still fires for working/ so the
|
// virtualUserHomeEntry below still fires for working/.
|
||||||
// user sees their own home placeholder.
|
if os.IsNotExist(err) && zddc.IsDeclaredPath(fsRoot, absDir) {
|
||||||
if os.IsNotExist(err) &&
|
|
||||||
(zddc.IsProjectRootFolder(dirPath) || zddc.IsArchivePartyFolder(dirPath)) {
|
|
||||||
entries = nil
|
entries = nil
|
||||||
} else {
|
} else {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -147,22 +145,22 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// virtualCanonicalFolders returns synthetic entries for any canonical
|
// virtualCanonicalFolders returns synthetic entries for any
|
||||||
// project-root folder absent from real. Fires only when dirPath is a
|
// cascade-declared child name that's absent from the on-disk
|
||||||
// depth-1 directory under fsRoot (the project root); other depths get
|
// listing. Sources from zddc.ChildrenDeclaredAt — the cascade's
|
||||||
// an empty slice. Case-insensitive presence check so an on-disk
|
// effective paths: at dirPath enumerates the expected children
|
||||||
// "Archive" suppresses the lowercase "archive" virtual entry.
|
// (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,
|
func virtualCanonicalFolders(fsRoot, dirPath, baseURL string,
|
||||||
real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo {
|
real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo {
|
||||||
|
|
||||||
rel := strings.Trim(filepath.ToSlash(dirPath), "/")
|
declared := zddc.ChildrenDeclaredAt(fsRoot, dirPath)
|
||||||
if rel == "" {
|
if len(declared) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
parts := strings.Split(rel, "/")
|
|
||||||
if len(parts) != 1 {
|
|
||||||
return nil // not a project root
|
|
||||||
}
|
|
||||||
|
|
||||||
present := make(map[string]bool, len(real))
|
present := make(map[string]bool, len(real))
|
||||||
for _, fi := range real {
|
for _, fi := range real {
|
||||||
|
|
@ -174,8 +172,8 @@ func virtualCanonicalFolders(fsRoot, dirPath, baseURL string,
|
||||||
}
|
}
|
||||||
|
|
||||||
var synth []listing.FileInfo
|
var synth []listing.FileInfo
|
||||||
for _, name := range zddc.ProjectRootFolders {
|
for _, name := range declared {
|
||||||
if present[name] {
|
if present[strings.ToLower(name)] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
synth = append(synth, listing.FileInfo{
|
synth = append(synth, listing.FileInfo{
|
||||||
|
|
|
||||||
|
|
@ -1300,7 +1300,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<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>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,14 @@ title: "ZDDC"
|
||||||
acl:
|
acl:
|
||||||
permissions: {}
|
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 ────────────────────────────────────────────
|
# ── Canonical project structure ────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# Every ZDDC project lives at a top-level directory. Under it the
|
# Every ZDDC project lives at a top-level directory. Under it the
|
||||||
|
|
@ -49,12 +57,14 @@ paths:
|
||||||
paths:
|
paths:
|
||||||
mdl:
|
mdl:
|
||||||
default_tool: tables
|
default_tool: tables
|
||||||
|
available_tools: [tables]
|
||||||
# The mdl folder is virtual by convention — the
|
# The mdl folder is virtual by convention — the
|
||||||
# tables tool serves it from the embedded default
|
# tables tool serves it from the embedded default
|
||||||
# spec even when the on-disk folder doesn't exist.
|
# spec even when the on-disk folder doesn't exist.
|
||||||
virtual: true
|
virtual: true
|
||||||
incoming:
|
incoming:
|
||||||
default_tool: classifier
|
default_tool: classifier
|
||||||
|
available_tools: [classifier]
|
||||||
# First write into incoming/ auto-creates an owner
|
# First write into incoming/ auto-creates an owner
|
||||||
# grant so the creator can manage their own drops.
|
# grant so the creator can manage their own drops.
|
||||||
auto_own: true
|
auto_own: true
|
||||||
|
|
@ -66,18 +76,27 @@ paths:
|
||||||
default_tool: archive
|
default_tool: archive
|
||||||
working:
|
working:
|
||||||
default_tool: mdedit
|
default_tool: mdedit
|
||||||
|
available_tools: [mdedit, classifier]
|
||||||
# working/ auto-owns the first creator + the per-user homes
|
# working/ auto-owns the first creator + the per-user homes
|
||||||
# below.
|
# below.
|
||||||
auto_own: true
|
auto_own: true
|
||||||
paths:
|
paths:
|
||||||
"*": # per-user home dir
|
"*": # per-user home dir
|
||||||
default_tool: mdedit
|
default_tool: mdedit
|
||||||
|
available_tools: [mdedit, classifier]
|
||||||
auto_own: true
|
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:
|
staging:
|
||||||
default_tool: transmittal
|
default_tool: transmittal
|
||||||
|
available_tools: [transmittal, classifier]
|
||||||
auto_own: true
|
auto_own: true
|
||||||
reviewing:
|
reviewing:
|
||||||
default_tool: mdedit
|
default_tool: mdedit
|
||||||
|
available_tools: [mdedit]
|
||||||
# reviewing/ is purely virtual — the aggregator handler
|
# reviewing/ is purely virtual — the aggregator handler
|
||||||
# synthesises listings from received/ ↔ staging/ ↔ issued/.
|
# synthesises listings from received/ ↔ staging/ ↔ issued/.
|
||||||
virtual: true
|
virtual: true
|
||||||
|
|
|
||||||
|
|
@ -205,10 +205,16 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
return target, err
|
return target, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if this newly-created ancestor is an auto-own position
|
// Determine if this newly-created ancestor is an auto-own
|
||||||
// and whether it should be fenced (inherit: false). 'i' is the
|
// position and whether it should be fenced (inherit: false).
|
||||||
// index into parentSegs (parentSegs[0] is the project segment).
|
// Resolved via the .zddc cascade — defaults.zddc.yaml
|
||||||
autoOwn, fenced := autoOwnDepthMatch(parentSegs, i)
|
// 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{
|
freshlyCreated = append(freshlyCreated, created{
|
||||||
absPath: pathSoFar,
|
absPath: pathSoFar,
|
||||||
autoOwn: autoOwn,
|
autoOwn: autoOwn,
|
||||||
|
|
@ -241,41 +247,6 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil
|
return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// autoOwnDepthMatch reports whether parentSegs[idx] sits at a canonical
|
// (autoOwnDepthMatch / isAutoOwnDepthMatch removed in Phase 3c —
|
||||||
// auto-own depth, and whether the auto-own .zddc should fence ancestor
|
// auto-own + fence determination now flows through the .zddc cascade
|
||||||
// cascade (inherit: false). parentSegs is the slash-relative path from
|
// via AutoOwnAt / AutoOwnFencedAt.)
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,14 @@ type ZddcFile struct {
|
||||||
// created. Empty (nil) inherits via cascade.
|
// created. Empty (nil) inherits via cascade.
|
||||||
AutoOwn *bool `yaml:"auto_own,omitempty" json:"auto_own,omitempty"`
|
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
|
// Virtual marks a directory as never-materialise-on-disk. The
|
||||||
// server treats requests under such a path as virtual routes
|
// server treats requests under such a path as virtual routes
|
||||||
// rather than triggering EnsureCanonicalAncestors. The reviewing
|
// rather than triggering EnsureCanonicalAncestors. The reviewing
|
||||||
|
|
@ -193,6 +201,23 @@ type ZddcFile struct {
|
||||||
// cascade.
|
// cascade.
|
||||||
Virtual *bool `yaml:"virtual,omitempty" json:"virtual,omitempty"`
|
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
|
// Paths declares virtual sub-directory rules without those
|
||||||
// directories needing to exist on disk. Each key is a single path
|
// directories needing to exist on disk. Each key is a single path
|
||||||
// segment — either a literal name or `*` (matches any segment).
|
// segment — either a literal name or `*` (matches any segment).
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,11 @@ func DefaultToolAt(fsRoot, dirPath string) string {
|
||||||
return chain.Embedded.DefaultTool
|
return chain.Embedded.DefaultTool
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutoOwnAt reports whether mkdir at this directory should write an
|
// AutoOwnAt reports whether mkdir at THIS specific directory should
|
||||||
// auto-owned .zddc. Returns the deepest explicit value found in the
|
// write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT
|
||||||
// cascade (true OR false), falling back to false when nothing set
|
// propagate to descendants (creating working/alice/notes/sub/ does
|
||||||
// anywhere. *bool semantics let descendants explicitly disable an
|
// not auto-own sub/; only the explicitly-declared per-user home is
|
||||||
// ancestor's auto_own: true.
|
// auto-owned).
|
||||||
//
|
//
|
||||||
// Replaces AutoOwnCanonicalNames once the file API's mkdir hook is
|
// Replaces AutoOwnCanonicalNames once the file API's mkdir hook is
|
||||||
// migrated.
|
// migrated.
|
||||||
|
|
@ -43,10 +43,9 @@ func AutoOwnAt(fsRoot, dirPath string) bool {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
leaf := leafLevel(chain)
|
||||||
if v := chain.Levels[i].AutoOwn; v != nil {
|
if leaf.AutoOwn != nil {
|
||||||
return *v
|
return *leaf.AutoOwn
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if v := chain.Embedded.AutoOwn; v != nil {
|
if v := chain.Embedded.AutoOwn; v != nil {
|
||||||
return *v
|
return *v
|
||||||
|
|
@ -54,9 +53,28 @@ func AutoOwnAt(fsRoot, dirPath string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// VirtualAt reports whether the directory at dirPath is declared as
|
// AutoOwnFencedAt reports whether the auto-own .zddc at this dir
|
||||||
// purely virtual. Walks the cascade like DefaultToolAt; deepest
|
// should be written with `inherit: false` (private to creator).
|
||||||
// explicit value wins.
|
// 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.
|
// Replaces VirtualOnlyCanonicalNames once consumers are migrated.
|
||||||
func VirtualAt(fsRoot, dirPath string) bool {
|
func VirtualAt(fsRoot, dirPath string) bool {
|
||||||
|
|
@ -64,10 +82,9 @@ func VirtualAt(fsRoot, dirPath string) bool {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
leaf := leafLevel(chain)
|
||||||
if v := chain.Levels[i].Virtual; v != nil {
|
if leaf.Virtual != nil {
|
||||||
return *v
|
return *leaf.Virtual
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if v := chain.Embedded.Virtual; v != nil {
|
if v := chain.Embedded.Virtual; v != nil {
|
||||||
return *v
|
return *v
|
||||||
|
|
@ -101,6 +118,50 @@ func IsDeclaredPath(fsRoot, dirPath string) bool {
|
||||||
return !isZeroZddcFile(leaf)
|
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
|
// ChildrenDeclaredAt returns the set of child directory names that
|
||||||
// the cascade declares should exist under dirPath. Includes
|
// the cascade declares should exist under dirPath. Includes
|
||||||
// wildcard "*" specs (caller decides how to expose those) and
|
// wildcard "*" specs (caller decides how to expose those) and
|
||||||
|
|
@ -148,7 +209,10 @@ func isZeroZddcFile(zf ZddcFile) bool {
|
||||||
if zf.DefaultTool != "" {
|
if zf.DefaultTool != "" {
|
||||||
return false
|
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
|
return false
|
||||||
}
|
}
|
||||||
if zf.AppsPubKey != "" || zf.CreatedBy != "" {
|
if zf.AppsPubKey != "" || zf.CreatedBy != "" {
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,13 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
||||||
if top.AutoOwn != nil {
|
if top.AutoOwn != nil {
|
||||||
out.AutoOwn = top.AutoOwn
|
out.AutoOwn = top.AutoOwn
|
||||||
}
|
}
|
||||||
|
if top.AutoOwnFenced != nil {
|
||||||
|
out.AutoOwnFenced = top.AutoOwnFenced
|
||||||
|
}
|
||||||
if top.Virtual != nil {
|
if top.Virtual != nil {
|
||||||
out.Virtual = top.Virtual
|
out.Virtual = top.Virtual
|
||||||
}
|
}
|
||||||
|
out.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools)
|
||||||
|
|
||||||
out.Admins = mergeStringSlice(out.Admins, top.Admins)
|
out.Admins = mergeStringSlice(out.Admins, top.Admins)
|
||||||
out.ACL.Allow = mergeStringSlice(out.ACL.Allow, top.ACL.Allow)
|
out.ACL.Allow = mergeStringSlice(out.ACL.Allow, top.ACL.Allow)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue