feat(zddc): dir_tool key — make the slash/no-slash routing convention configurable
The trailing-slash directory form was hardcoded to serve `browse`. Add a `dir_tool` .zddc key (cascades leaf→root, floors at `browse`) so an operator can point a subtree's slash form at another directory-oriented tool — the symmetric counterpart to `default_tool` (the no-slash "specialized app"). handler.ServeDirectory now resolves it via zddc.DirToolAt; JSON listing requests are unaffected (raw listing always served, so browse can still enumerate). Also collapse the no-slash dispatch: the on-disk-directory and the virtual-declared-path branches in main.go each carried their own copy of "default_tool → tables-carveout-or-apps.Serve → 302", with inconsistent ACL checks. Extract one chokepoint, serveSpecializedNoSlash, that enforces ACL uniformly for every default_tool route. Updates ARCHITECTURE.md and AGENTS.md: the stale "Special folders" / hardcoded-availability sections now describe the .zddc-cascade model (defaults.zddc.yaml, the schema-key table, the slash/no-slash convention, WORM, standard roles). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c8d0afd1b8
commit
bb5e059477
9 changed files with 216 additions and 97 deletions
11
AGENTS.md
11
AGENTS.md
|
|
@ -284,18 +284,11 @@ The build pipeline used is the one **at the tag**, not on `main`. That is intent
|
||||||
No install script. Two paths:
|
No install script. Two paths:
|
||||||
|
|
||||||
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
|
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
|
||||||
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). The server virtually serves them at folder-name-driven paths:
|
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` everywhere, `transmittal` under `staging/`, `mdedit` under `working/`, `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
|
||||||
- `archive.html` at every directory (multi-project, project, archive, vendor levels)
|
|
||||||
- `classifier.html` in any `Incoming`/`Working`/`Staging` directory and its subtree
|
|
||||||
- `mdedit.html` in any `Working` directory and its subtree
|
|
||||||
- `transmittal.html` in any `Staging` directory and its subtree
|
|
||||||
- `index.html` (landing) only at the deployment root
|
|
||||||
|
|
||||||
See `internal/apps/availability.go`. Outside these locations, requesting `<app>.html` returns 404 (just like any other missing file).
|
|
||||||
|
|
||||||
To override at any level, either:
|
To override at any level, either:
|
||||||
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
|
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
|
||||||
2. Write an `apps:` entry in any `.zddc` along the path. Spec is one of `stable`/`beta`/`alpha`/`v0.0.4`/`v0.0`/`v0`/full URL/local path. Closer-to-leaf entries win.
|
2. Write an `apps:` entry in any `.zddc` along the path. Spec is one of `stable`/`beta`/`alpha`/`v0.0.4`/`v0.0`/`v0`/full URL/local path. Closer-to-leaf entries win. (Or change `default_tool` / `dir_tool` / `available_tools` to route a different tool entirely.)
|
||||||
|
|
||||||
URL sources fetch once and cache forever in `<ZDDC_ROOT>/_app/<host>/<path>`. To force a re-fetch, delete the cache file. No background refresh, no SHA-256 verification, no admin UI. If a configured URL fetch fails, the server falls back to the embedded copy and emits a one-time WARN log.
|
URL sources fetch once and cache forever in `<ZDDC_ROOT>/_app/<host>/<path>`. To force a re-fetch, delete the cache file. No background refresh, no SHA-256 verification, no admin UI. If a configured URL fetch fails, the server falls back to the embedded copy and emits a one-time WARN log.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -690,14 +690,30 @@ Cascade evaluation walks leaf→root for the first level whose entries match the
|
||||||
|
|
||||||
The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypass: root admins (`IsAdmin`) and subtree admins (`IsSubtreeAdmin`) get unconditional `rwcda` and skip both the cascade and the WORM mask.
|
The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypass: root admins (`IsAdmin`) and subtree admins (`IsSubtreeAdmin`) get unconditional `rwcda` and skip both the cascade and the WORM mask.
|
||||||
|
|
||||||
#### Special folders
|
#### Canonical folders, URL routing & the `.zddc` cascade
|
||||||
|
|
||||||
Five folder names drive built-in behaviors (canonical list in `zddc/internal/zddc/special.go`):
|
There are **no hardcoded folder names** — the canonical project structure (`archive/`, `working/`, `staging/`, `reviewing/`; `archive/<party>/{mdl,incoming,received,issued}/`) is described by a baked-in baseline `.zddc` (`zddc/internal/zddc/defaults.zddc.yaml`), loaded as the bottom layer of the cascade. `zddc-server show-defaults` dumps it; operators override at the on-disk root (or any deeper level) by mirroring the structure and changing what they need (on-disk wins per field). Setting file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer entirely — **including the structural convention (WORM zones, per-user fences, virtual folders)**, not just the default ACLs, so it's a blunt instrument.
|
||||||
|
|
||||||
- `Incoming`, `Working`, `Staging` — auto-ownership on mkdir. The file API's `POST X-ZDDC-Op: mkdir` writes a `.zddc` into the new subdirectory granting the creator's email `rwcda` directly. The grant is identical in form to operator-authored entries; the creator can edit it later to add collaborators.
|
The schema keys that drive built-in behavior:
|
||||||
- `Issued`, `Received` — write-once / immutable archive. Server-side **WORM split**: at any path crossing an `Issued` or `Received` segment, ancestor cascade grants are masked to `r` only; verbs at-or-below the WORM folder retain `r,c`. To grant `cr` (drop-box) to a doc controller, the operator places a `.zddc` at the `Issued`/`Received` folder explicitly listing the role. No principal can `w`/`d`/`a` inside the archive — only admins can mutate filed documents.
|
|
||||||
|
|
||||||
The user-stated "drop box" archetype is the doc controller's `cr` set in Issued/Received: they can file new documents but cannot overwrite, delete, or change ACLs after.
|
| Key | Effect | Cascade rule |
|
||||||
|
|---|---|---|
|
||||||
|
| `default_tool` | tool served at `<dir>` (no trailing slash) — the "specialized app" | leaf→root (parent applies to descendants) |
|
||||||
|
| `dir_tool` | tool served at `<dir>/` (trailing slash) — the directory view; floors at `browse` | leaf→root |
|
||||||
|
| `auto_own` / `auto_own_fenced` | mkdir here writes a creator-owned `.zddc` (`<email>: rwcda`); fenced adds `acl.inherit:false` (private) | leaf-only |
|
||||||
|
| `virtual` | never materialise on disk; requests are virtual routes (`reviewing/`, `mdl`) | leaf-only |
|
||||||
|
| `drop_target` | browse shows a drag-drop upload overlay (surfaced via `X-ZDDC-Drop-Target`) | leaf-only |
|
||||||
|
| `worm` | list of principals — see WORM below | union across cascade (no reset) |
|
||||||
|
| `available_tools` | tools the server may auto-serve / browse may offer here | union leaf→root |
|
||||||
|
| `admins` | subtree-admin principals (email globs or role names) | concat-dedupe across cascade |
|
||||||
|
| `roles` | `{ name → { members:[], reset:bool } }` | members union across cascade; `reset:true` stops the walk |
|
||||||
|
| `paths` | recursive map of child-path → `.zddc` overlay; the engine of the whole convention | replaces (the walker threads ancestor `paths:` to the right level) |
|
||||||
|
|
||||||
|
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
|
||||||
|
|
||||||
|
**WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Admins (root / subtree) bypass entirely — the escape hatch for mis-filed documents. `defaults.zddc.yaml` puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change.
|
||||||
|
|
||||||
|
**Standard roles.** `defaults.zddc.yaml` references two roles (both shipped empty — a fresh deployment grants nothing until an operator populates them): `document_controller` (read/write across a project, `rwc` at `archive/`, subtree-admin of `working/` and `staging/`, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow) and `project_team` (read-only across the project; their own `working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule).
|
||||||
|
|
||||||
### File API (authenticated CRUD)
|
### File API (authenticated CRUD)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -574,6 +574,55 @@ func embeddedVersionsForLog(embedded map[string]string) string {
|
||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serveSpecializedNoSlash handles a GET/HEAD request to a directory
|
||||||
|
// URL without a trailing slash by serving the directory's cascade-
|
||||||
|
// declared default_tool — the "specialized app" half of the slash/
|
||||||
|
// no-slash routing convention. (The slash half is DirTool, resolved
|
||||||
|
// in handler.ServeDirectory; it defaults to "browse".) Works for both
|
||||||
|
// real on-disk directories and purely-virtual ones (default_tool may
|
||||||
|
// come from an ancestor's paths: tree).
|
||||||
|
//
|
||||||
|
// Returns true once it has written a response. Returns false when
|
||||||
|
// there is nothing specialized to serve — no default_tool, or
|
||||||
|
// default_tool=tables with no matching table spec, or the tool isn't
|
||||||
|
// available at this path — so the caller falls through to its own
|
||||||
|
// fallback (landing at a project root, then a 302 to the slash form
|
||||||
|
// where DirTool/browse renders the listing).
|
||||||
|
//
|
||||||
|
// ACL is enforced here against dirAbs's effective policy, so every
|
||||||
|
// default_tool route is gated identically regardless of which call
|
||||||
|
// site reached it. dirAbs is the directory's filesystem path (it need
|
||||||
|
// not exist on disk); urlPath is the request URL path; email is the
|
||||||
|
// authenticated user (may be empty).
|
||||||
|
func serveSpecializedNoSlash(cfg config.Config, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request, dirAbs, urlPath, email string) bool {
|
||||||
|
app := apps.DefaultAppAt(cfg.Root, dirAbs)
|
||||||
|
if app == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
chain, _ := zddc.EffectivePolicy(cfg.Root, dirAbs)
|
||||||
|
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if app == "tables" {
|
||||||
|
// tables isn't an apps-subsystem app — it's the table view,
|
||||||
|
// served by handler.ServeTable from a synthesized
|
||||||
|
// <dir>/table.html request (which also resolves the embedded
|
||||||
|
// default-MDL spec for archive/<party>/mdl). No spec → caller
|
||||||
|
// falls through.
|
||||||
|
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, urlPath+"/table.html"); tr != nil {
|
||||||
|
handler.ServeTable(cfg, tr, w, r)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if appsSrv != nil && apps.AppAvailableAt(cfg.Root, dirAbs, app) {
|
||||||
|
appsSrv.Serve(w, r, app, chain, dirAbs)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// dispatch routes a request to the appropriate handler.
|
// dispatch routes a request to the appropriate handler.
|
||||||
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, tokens *auth.Store, w http.ResponseWriter, r *http.Request) {
|
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, tokens *auth.Store, w http.ResponseWriter, r *http.Request) {
|
||||||
// URL paths are case-insensitive: resolve each segment against the
|
// URL paths are case-insensitive: resolve each segment against the
|
||||||
|
|
@ -897,47 +946,21 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
// Cascade-declared paths: the .zddc cascade (embedded
|
// Cascade-declared paths: the .zddc cascade (embedded
|
||||||
// defaults + on-disk overrides) declares this URL even
|
// defaults + on-disk overrides) declares this URL even
|
||||||
// if the on-disk directory doesn't exist yet. Land on a
|
// if the on-disk directory doesn't exist yet. Land on a
|
||||||
// usable view rather than 404 — usually the slash/no-slash
|
// usable view rather than 404, via the same slash/no-slash
|
||||||
// convention serves:
|
// routing convention used for real directories:
|
||||||
// - no-slash, default_tool=tables → ServeTable
|
// - slash → ServeDirectory (DirTool; browse by default)
|
||||||
// (default-MDL fallback)
|
// - no-slash → default_tool ("specialized app") if any,
|
||||||
// - no-slash, default_tool set → apps.Serve(tool)
|
// else a 302 to the slash form.
|
||||||
// - no-slash, no default_tool → 302 to slash form
|
|
||||||
// - slash, any → ServeDirectory
|
|
||||||
// (empty-listing fallback)
|
|
||||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||||
zddc.IsDeclaredPath(cfg.Root, absPath) {
|
zddc.IsDeclaredPath(cfg.Root, absPath) {
|
||||||
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
|
if strings.HasSuffix(urlPath, "/") {
|
||||||
if !strings.HasSuffix(urlPath, "/") {
|
handler.ServeDirectory(cfg, appsSrv, w, r)
|
||||||
// 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 {
|
|
||||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handler.ServeTable(cfg, tr, w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 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) {
|
|
||||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
appsSrv.Serve(w, r, app, chain, absPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No default tool — fall through to slash form.
|
|
||||||
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler.ServeDirectory(cfg, appsSrv, w, r)
|
if serveSpecializedNoSlash(cfg, appsSrv, w, r, absPath, urlPath, email) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
|
@ -961,39 +984,18 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// URL convention: trailing slash → browse (handled by
|
// Slash/no-slash routing convention: trailing slash → the
|
||||||
// ServeDirectory, which serves browse.html for HTML requests
|
// directory view (handler.ServeDirectory → DirTool, which
|
||||||
// and JSON for application/json). No trailing slash → the
|
// resolves to browse by default; JSON requests always get the
|
||||||
// canonical default tool for this directory's context, if any
|
// raw listing regardless). No trailing slash → the directory's
|
||||||
// (mdedit under working/, transmittal under staging/, archive
|
// default_tool ("specialized app") — mdedit under working/,
|
||||||
// under archive/, tables under archive/<party>/mdl/). When no
|
// transmittal under staging/, archive under archive/, tables
|
||||||
// default applies, fall back to the historical redirect-to-
|
// under archive/<party>/mdl/ — if one is declared; otherwise
|
||||||
// trailing-slash behaviour.
|
// (after the project-root landing case below) a 302 to the
|
||||||
|
// slash form.
|
||||||
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 {
|
||||||
app := apps.DefaultAppAt(cfg.Root, absPath)
|
if serveSpecializedNoSlash(cfg, appsSrv, w, r, absPath, urlPath, email) {
|
||||||
switch app {
|
return
|
||||||
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
|
|
||||||
// equivalent table view inline at the bare URL by
|
|
||||||
// synthesizing the canonical /table.html suffix.
|
|
||||||
synth := urlPath + "/table.html"
|
|
||||||
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil {
|
|
||||||
handler.ServeTable(cfg, tr, w, r)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Project root (depth-1 dir, no trailing slash) serves the
|
// Project root (depth-1 dir, no trailing slash) serves the
|
||||||
|
|
|
||||||
|
|
@ -171,15 +171,20 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browser HTML fallback: serve the `browse` tool. By default it's
|
// Browser HTML fallback: serve the directory's DirTool — the
|
||||||
// the embedded copy (single-file SPA whose autoDetectServerMode
|
// trailing-slash half of the slash/no-slash convention. It
|
||||||
// loads the JSON listing for the current directory and renders it
|
// resolves to "browse" by default (the single-file file-tree SPA
|
||||||
// as a sortable, filterable tree). A `.zddc apps: browse:` entry
|
// whose autoDetectServerMode loads the JSON listing for the
|
||||||
// up the chain can override with a path or URL source — when
|
// current directory and renders it as a sortable, filterable
|
||||||
// appsSrv is wired up, delegate to it so cascade entries are
|
// tree); an operator's `.zddc dir_tool:` can point a subtree's
|
||||||
// honored at directory URLs too (not just /<dir>/browse.html).
|
// slash form at another directory-oriented tool. Either way it
|
||||||
if appsSrv != nil {
|
// goes through the apps subsystem when wired up, so `.zddc apps:`
|
||||||
appsSrv.Serve(w, r, "browse", chain, absDir)
|
// source overrides are honored at directory URLs too (not just
|
||||||
|
// /<dir>/<tool>.html). When appsSrv is nil we serve the embedded
|
||||||
|
// browse copy directly — same behavior as before the hook.
|
||||||
|
dirTool := zddc.DirToolAt(cfg.Root, absDir)
|
||||||
|
if appsSrv != nil && zddc.IsKnownApp(dirTool) {
|
||||||
|
appsSrv.Serve(w, r, dirTool, chain, absDir)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body := apps.EmbeddedBytes("browse")
|
body := apps.EmbeddedBytes("browse")
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,32 @@ roles:
|
||||||
# ancestor entries — so this baseline propagates to every descendant.
|
# ancestor entries — so this baseline propagates to every descendant.
|
||||||
available_tools: [archive, browse, landing]
|
available_tools: [archive, browse, landing]
|
||||||
|
|
||||||
|
# ── The slash / no-slash routing convention ────────────────────────────────
|
||||||
|
#
|
||||||
|
# Every directory URL has two forms, each served by a configurable
|
||||||
|
# tool:
|
||||||
|
#
|
||||||
|
# <dir>/ (trailing slash) → `dir_tool` — the directory view.
|
||||||
|
# Defaults to `browse` (file-tree
|
||||||
|
# navigator). This is the site-wide
|
||||||
|
# default; you rarely set it.
|
||||||
|
# <dir> (no slash) → `default_tool` — the "specialized
|
||||||
|
# app" for this folder (e.g. archive,
|
||||||
|
# transmittal, mdedit, tables). If a
|
||||||
|
# folder declares no default_tool, the
|
||||||
|
# no-slash form just 302s to the slash
|
||||||
|
# form, so you land on `dir_tool`.
|
||||||
|
#
|
||||||
|
# JSON listing requests are unaffected by either key — they always get
|
||||||
|
# the raw directory listing, so the browse SPA (and any other client)
|
||||||
|
# can enumerate entries no matter what dir_tool/default_tool are.
|
||||||
|
#
|
||||||
|
# Both keys cascade leaf→root: a parent's default_tool applies to
|
||||||
|
# descendants unless a deeper level overrides it (mdedit set on
|
||||||
|
# working/ reaches working/alice/notes/ for free). The keys below set
|
||||||
|
# default_tool on the canonical folders; dir_tool is left unset
|
||||||
|
# everywhere, so the slash form is always `browse`.
|
||||||
|
#
|
||||||
# ── 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
|
||||||
|
|
|
||||||
|
|
@ -186,12 +186,25 @@ type ZddcFile struct {
|
||||||
|
|
||||||
// DefaultTool is the tool name served at this directory's
|
// DefaultTool is the tool name served at this directory's
|
||||||
// no-slash URL form (e.g. /Project/working without trailing slash
|
// no-slash URL form (e.g. /Project/working without trailing slash
|
||||||
// → mdedit). Empty means "no default" — the slash convention's
|
// → mdedit). Empty means "no default" — the no-slash form 302s to
|
||||||
// browse listing wins and the no-slash form 302s. Cascades
|
// the slash form, which serves DirTool (browse by default).
|
||||||
// through Paths: an ancestor's Paths entry can set DefaultTool
|
// Cascades through Paths: an ancestor's Paths entry can set
|
||||||
// for a virtual descendant without anyone creating that dir.
|
// DefaultTool for a virtual descendant without anyone creating
|
||||||
|
// that dir. This is the "specialized app" half of the slash/no-
|
||||||
|
// slash convention; see DirTool for the other half.
|
||||||
DefaultTool string `yaml:"default_tool,omitempty" json:"default_tool,omitempty"`
|
DefaultTool string `yaml:"default_tool,omitempty" json:"default_tool,omitempty"`
|
||||||
|
|
||||||
|
// DirTool is the tool name served at this directory's TRAILING-
|
||||||
|
// SLASH URL form (e.g. /Project/working/ → the directory view).
|
||||||
|
// Empty resolves to "browse" — the file-tree navigator — which is
|
||||||
|
// the site-wide default. An operator can override it per subtree
|
||||||
|
// (rare; e.g. a folder whose slash form should render some other
|
||||||
|
// directory-oriented view). JSON listing requests are unaffected:
|
||||||
|
// they always get the raw listing regardless of DirTool, so the
|
||||||
|
// browse SPA (or any client) can still enumerate entries.
|
||||||
|
// Cascades leaf→root like DefaultTool.
|
||||||
|
DirTool string `yaml:"dir_tool,omitempty" json:"dir_tool,omitempty"`
|
||||||
|
|
||||||
// AutoOwn controls whether the file API's mkdir post-hook writes
|
// AutoOwn controls whether the file API's mkdir post-hook writes
|
||||||
// an auto-owned .zddc granting the creator rwcda at the new
|
// an auto-owned .zddc granting the creator rwcda at the new
|
||||||
// directory. Useful for working/staging/incoming-style drafting
|
// directory. Useful for working/staging/incoming-style drafting
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,31 @@ func DefaultToolAt(fsRoot, dirPath string) string {
|
||||||
return chain.Embedded.DefaultTool
|
return chain.Embedded.DefaultTool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DirToolAt returns the cascade-resolved tool name served at the
|
||||||
|
// directory's TRAILING-SLASH URL form. Walks chain.Levels leaf→root
|
||||||
|
// (then the embedded defaults), returning the first non-empty
|
||||||
|
// DirTool. Floors at "browse": an undeclared directory serves the
|
||||||
|
// file-tree navigator, which is the site-wide convention. So callers
|
||||||
|
// never need to special-case the empty result.
|
||||||
|
//
|
||||||
|
// This is the slash half of the slash/no-slash routing convention;
|
||||||
|
// DefaultToolAt is the no-slash half.
|
||||||
|
func DirToolAt(fsRoot, dirPath string) string {
|
||||||
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||||
|
if err != nil {
|
||||||
|
return "browse"
|
||||||
|
}
|
||||||
|
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
||||||
|
if dt := chain.Levels[i].DirTool; dt != "" {
|
||||||
|
return dt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dt := chain.Embedded.DirTool; dt != "" {
|
||||||
|
return dt
|
||||||
|
}
|
||||||
|
return "browse"
|
||||||
|
}
|
||||||
|
|
||||||
// AutoOwnAt reports whether mkdir at THIS specific directory should
|
// AutoOwnAt reports whether mkdir at THIS specific directory should
|
||||||
// write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT
|
// write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT
|
||||||
// propagate to descendants (creating working/alice/notes/sub/ does
|
// propagate to descendants (creating working/alice/notes/sub/ does
|
||||||
|
|
@ -216,7 +241,7 @@ func isZeroZddcFile(zf ZddcFile) bool {
|
||||||
if zf.Title != "" {
|
if zf.Title != "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if zf.DefaultTool != "" {
|
if zf.DefaultTool != "" || zf.DirTool != "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if zf.AutoOwn != nil || zf.AutoOwnFenced != nil || zf.Virtual != nil ||
|
if zf.AutoOwn != nil || zf.AutoOwnFenced != nil || zf.Virtual != nil ||
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,42 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDirToolAt — the trailing-slash form floors at "browse" for
|
||||||
|
// every path (the embedded convention sets dir_tool nowhere), and an
|
||||||
|
// on-disk .zddc can override it for a subtree.
|
||||||
|
func TestDirToolAt(t *testing.T) {
|
||||||
|
resetCache()
|
||||||
|
root := t.TempDir()
|
||||||
|
// Nothing declares dir_tool → browse everywhere, including paths
|
||||||
|
// whose default_tool (no-slash form) is something else.
|
||||||
|
for _, p := range []string{
|
||||||
|
filepath.Join(root, "Project-X"),
|
||||||
|
filepath.Join(root, "Project-X", "working"),
|
||||||
|
filepath.Join(root, "Project-X", "archive", "Acme", "mdl"),
|
||||||
|
filepath.Join(root, "Project-X", "random", "deep", "folder"),
|
||||||
|
} {
|
||||||
|
if got := DirToolAt(root, p); got != "browse" {
|
||||||
|
t.Errorf("DirToolAt(%q) = %q, want browse", p[len(root):], got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Operator override on a subtree; cascades leaf→root.
|
||||||
|
specialDir := filepath.Join(root, "Special")
|
||||||
|
if err := os.MkdirAll(specialDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeZddc(t, specialDir, "dir_tool: tables\n")
|
||||||
|
resetCache()
|
||||||
|
if got := DirToolAt(root, filepath.Join(root, "Special")); got != "tables" {
|
||||||
|
t.Errorf("DirToolAt(Special) = %q, want tables", got)
|
||||||
|
}
|
||||||
|
if got := DirToolAt(root, filepath.Join(root, "Special", "deep")); got != "tables" {
|
||||||
|
t.Errorf("DirToolAt(Special/deep) = %q, want tables (cascade)", got)
|
||||||
|
}
|
||||||
|
if got := DirToolAt(root, filepath.Join(root, "Other")); got != "browse" {
|
||||||
|
t.Errorf("DirToolAt(Other) = %q, want browse (override scoped to Special)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for
|
// TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for
|
||||||
// working/incoming/staging (per the convention) and false elsewhere.
|
// working/incoming/staging (per the convention) and false elsewhere.
|
||||||
func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
||||||
if top.DefaultTool != "" {
|
if top.DefaultTool != "" {
|
||||||
out.DefaultTool = top.DefaultTool
|
out.DefaultTool = top.DefaultTool
|
||||||
}
|
}
|
||||||
|
if top.DirTool != "" {
|
||||||
|
out.DirTool = top.DirTool
|
||||||
|
}
|
||||||
if top.AutoOwn != nil {
|
if top.AutoOwn != nil {
|
||||||
out.AutoOwn = top.AutoOwn
|
out.AutoOwn = top.AutoOwn
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue