refactor: unified listing protocol + form-editor retirement + admin elevation

Three coordinated changes that share the same files. Common theme:
convention beats exception. Where the codebase had a bespoke wire shape
or a special-case route, replace it with the generic shape every other
client already speaks.

== Listing protocol ==

GET / Accept: application/json used to dispatch to a bespoke
ServeProjectList handler returning {name, url, title} per project — a
shape that diverged from every other directory's listing.FileInfo
response. Now:

- listing.FileInfo gains an optional `title` field (read from each
  directory's own .zddc title:). Generic clients (landing, browse)
  read the same shape from every URL.
- appfs.ListDirectory emits a virtual `.zddc` entry (is_dir:false,
  virtual:true) when no on-disk file exists at that path and the
  caller asked for ?hidden=1. Opens an editable view of the cascade
  defaults; PUT-saving its bytes materialises a real file.
- The bespoke GET / JSON branch in cmd/zddc-server/main.go is gone.
  The bare-root landing serve is Accept-gated: HTML requests get the
  landing tool (project picker), JSON requests fall through to
  ServeDirectory and get the generic listing.
- landing's fetchProjects filters the new generic shape (is_dir,
  strip trailing slash) — same pattern fetchParties already used at
  /<project>/archive/.

== Form editor retirement ==

`<dir>/.zddc.html` was a server-rendered form for editing per-directory
.zddc files (~900 LOC across zddceditor.go, zddchandler.go, zddc_assets.go).
Browse's YAML/CodeMirror editor (with .zddc-schema lint) already edits
the same files via the generic file-API. Two ways to edit the same data
is exception, not convention.

- Delete zddceditor.go, zddchandler.go, zddc_assets.go and tests.
- `/<dir>/.zddc.html` → 302 redirect to `/<dir>/?file=.zddc` (browse
  opens the .zddc in its editor pane).
- /.profile/zddc/* namespace deleted (REST API + assets sub-route).
- Profile page's "Editable .zddc files" list links to browse.
- ServeZddcFile's 405 message + virtual-body comment point at the
  browse URL instead of the dead form.

== Admin elevation (Principal model) ==

Sudo-style: admins are treated as normal users by default; opting into
admin powers is per-request and gated by a `zddc-elevate=1` cookie.

- zddc.Principal{Email, Elevated} replaces bare-email arguments on
  IsAdmin / IsSubtreeAdmin / CanEditZddc. The signature change makes
  the elevation gate compiler-enforced at every admin call site —
  audit-fragility is gone. The empty-email short-circuit is no longer
  load-bearing for elevation; Principal.gate() is the explicit check.
- handler.ACLMiddleware derives Elevated per request: bearer tokens
  are implicitly elevated (CLI clients can't toggle a cookie); browser
  sessions elevate only when zddc-elevate=1 is set. PrincipalFromContext(r)
  is the one-call-per-site bundling helper.
- Every admin-check call site updated to pass a Principal.
- /.auth/admin (forward_auth target for the dev-shell IDE) explicitly
  bypasses elevation with a synthetic-elevated Principal — different
  cookie scope than zddc-server origin, documented inline.
- AccessView gains CanElevate (elevation-independent "does this email
  have admin authority anywhere?") so the header toggle can render
  itself for an un-elevated admin who hasn't opted in yet.
- ServeProjectList is removed; ProjectInfo + EnumerateProjects stay
  for the profile page's server-rendered project list.
- MatchAppHTML stays — still used by main.go to route <dir>/<tool>.html
  URLs to the apps subsystem when no real file exists.
- Test helpers carry Elevated=true by default (matches the
  pre-elevation default; tests for the un-elevated gate use the
  explicit form).

Go tests pass across all 14 internal packages. Browse + every other
tool rebuilds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-14 12:15:07 -05:00
parent 94b2e29448
commit 2d114fcb96
27 changed files with 689 additions and 1657 deletions

View file

@ -128,13 +128,27 @@
var data = JSON.parse(body);
if (!Array.isArray(data)) throw new Error('Expected a JSON array of projects, got ' + typeof data);
allProjects = data.map(function(p) {
// The root JSON is now a generic listing.FileInfo[] (same
// shape every other directory returns). Filter to
// directories (projects are folders), strip the trailing
// "/" the server adds to dir names, and pick up `title`
// (the per-project .zddc title:, populated by the
// server-side listing pipeline).
allProjects = data
.filter(function (p) { return p && p.is_dir; })
.map(function (p) {
var raw = String(p.name || '').replace(/\/$/, '');
return {
name: String(p.name || ''),
name: raw,
title: String(p.title || ''),
url: String(p.url || '')
};
}).filter(function(p) { return p.name; });
})
.filter(function (p) {
if (!p.name) return false;
var c = p.name.charAt(0);
return c !== '.' && c !== '_';
});
return true;
} catch (e) {
loadError = e.message || String(e);

View file

@ -754,25 +754,27 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
// Project list API: GET / with Accept: application/json
if urlPath == "/" {
accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/json") {
handler.ServeProjectList(cfg, w, r)
return
}
}
// (Project list at GET / with Accept: application/json used to be
// served by a bespoke handler that returned a custom JSON shape.
// Removed in favour of routing /through the generic ServeDirectory:
// the directory listing now carries `title` per entry, so the
// landing page reads project names from the same shape every other
// listing has. Single canonical wire format > exception that
// reveals a special perspective.)
// Split path into segments
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
// Per-directory .zddc editor: <dir>/.zddc.html is a virtual URL
// served by the existing form-based editor (same handler that
// powers /.profile/zddc/edit?path=<dir>). Routed BEFORE the
// dot-prefix guard so the leaf segment isn't 404'd. The handler
// itself gates on hasAnyAdminScope; non-admins see 404.
if handler.IsZddcEditorRequest(urlPath) {
handler.ServeZddcEditorAtPath(cfg, w, r)
// Legacy `<dir>/.zddc.html` form-editor URL → 302 redirect to
// the canonical edit surface (browse opening `.zddc` in its
// YAML/CodeMirror pane). The form editor was retired in favour
// of one canonical edit surface; bookmarks still resolve.
if urlPath == "/.zddc.html" || strings.HasSuffix(urlPath, "/.zddc.html") {
dir := strings.TrimSuffix(urlPath, ".zddc.html")
if dir == "" {
dir = "/"
}
http.Redirect(w, r, dir+"?file=.zddc", http.StatusFound)
return
}
@ -780,8 +782,8 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// and returns the on-disk file's bytes (Content-Type: application/yaml)
// or — when no file exists — a synthetic placeholder body with a
// cascade summary so the user can see what's effective here.
// GET/HEAD only; writes go through the admin-gated .zddc.html
// form. Also carved out of the dot-prefix guard.
// GET/HEAD only; writes go through the file API (PUT). Carved
// out of the dot-prefix guard so the leaf segment isn't 404'd.
if handler.IsZddcFileRequest(urlPath) {
handler.ServeZddcFile(cfg, w, r)
return
@ -960,12 +962,25 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// other four apps are caught by the "stat fails → app HTML?" branch
// below, which only triggers when no concrete file is at the URL path.
//
// Gated by Accept: HTML requests get the landing tool, JSON requests
// fall through to ServeDirectory and get the generic listing (with
// per-entry titles via listing.FileInfo.Title). That keeps the wire
// protocol uniform — a JSON listing is a JSON listing whether you
// fetch /Project-1/ or /. Landing itself consumes the same shape.
//
// The landing page is intentionally public (no ACL gate). It's a
// project picker — the per-project ACL filtering done by
// fs.ListDirectory still hides projects an anonymous (or unauthorized)
// caller can't reach. See also handler.ServeDirectory's matching
// root-path bypass.
if appsSrv != nil && (urlPath == "/" || urlPath == "/index.html") {
//
// (Browsers normalize `https://host` → `https://host/`, so the
// no-slash vs slash distinction the user might want — picker on
// bare host, browse on trailing slash — can't be expressed: the
// HTTP request for both forms is `GET /`. The picker wins because
// it's the only meaningful entry point that scopes ACL per-project.)
if appsSrv != nil && (urlPath == "/" || urlPath == "/index.html") &&
!strings.Contains(r.Header.Get("Accept"), "application/json") {
realIndex := filepath.Join(cfg.Root, "index.html")
if _, err := os.Stat(realIndex); os.IsNotExist(err) {
chain, _ := zddc.EffectivePolicy(cfg.Root, cfg.Root)

View file

@ -34,10 +34,16 @@ func NewServer(root string, cache *Cache, fetcher *Fetcher, buildVer string) *Se
}
// MatchAppHTML returns the canonical app name if requestPath matches a
// "<dir>/<app>.html" pattern for one of the five canonical apps, plus the
// directory (relative to root) the request is rooted at.
// "<dir>/<app>.html" pattern for one of the canonical apps, plus the
// directory (relative to root) the request is rooted at. The cmd/zddc-
// server dispatcher calls this when stat fails on a URL: a missing file
// that happens to look like `<dir>/archive.html` (or browse.html, etc.)
// resolves to the embedded app HTML for that directory — operators
// don't have to copy app HTML into every project.
//
// Special case: GET / and GET /index.html both resolve to landing.
// Special case: GET / and GET /index.html both resolve to landing — the
// only entry point that scopes ACL per-project, and the conventional
// place for a static-site index when an operator wants one.
func MatchAppHTML(requestPath string) (app string, requestDirRel string) {
if requestPath == "" || requestPath == "/" {
return "landing", ""

View file

@ -116,6 +116,15 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
if !allowed {
continue // omit denied directories silently
}
// Pull the title from this subdir's own .zddc, if it has
// one. Lets clients render project / folder names without
// a second round-trip per entry — the landing page used
// to need a bespoke /api with this info; now the generic
// listing carries it.
var title string
if zf, perr := zddc.ParseFile(filepath.Join(subAbs, ".zddc")); perr == nil {
title = zf.Title
}
fi := listing.FileInfo{
Name: name + "/",
Size: info.Size(),
@ -125,6 +134,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
IsDir: true,
DisplayName: displayName,
Declared: declared,
Title: title,
}
result = append(result, fi)
continue
@ -161,9 +171,44 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
// to real ones.
result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...)
// Surface a virtual `.zddc` entry when the on-disk file doesn't
// exist. The /<dir>/.zddc URL always serves SOMETHING — real
// bytes if present, a synthetic placeholder body otherwise (see
// handler.ServeZddcFile) — so the entry resolves to a real
// editable view either way. PUT-ing back materialises the file
// on disk and the listing converts to a real (non-virtual) row
// automatically on the next fetch. Only emitted when the caller
// asked for hidden entries (?hidden=1), matching the dot-prefix
// hide rule used for every other dotfile.
if includeHidden {
if v, ok := virtualZddcEntry(absDir, baseURL); ok {
result = append(result, v)
}
}
return result, nil
}
// virtualZddcEntry returns a synthetic listing entry for absDir/.zddc
// when no real file exists. The cascade has effective rules at every
// path (down through embedded defaults), so editing this virtual
// entry is always meaningful — a save promotes it to a real on-disk
// .zddc that overrides ancestor levels for this directory.
func virtualZddcEntry(absDir, baseURL string) (listing.FileInfo, bool) {
zddcPath := filepath.Join(absDir, ".zddc")
if _, err := os.Stat(zddcPath); err == nil {
return listing.FileInfo{}, false
} else if !os.IsNotExist(err) {
return listing.FileInfo{}, false
}
return listing.FileInfo{
Name: ".zddc",
URL: baseURL + ".zddc",
IsDir: false,
Virtual: true,
}, true
}
// 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

View file

@ -39,7 +39,14 @@ const AuthPathPrefix = "/.auth"
// consult AllowedWithChain.
func ServeAuthAdmin(cfg config.Config, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if email == "" || !zddc.IsAdmin(cfg.Root, email) {
// Elevation-independent gate. Upstream proxies (Caddy forward_auth
// for the dev-shell IDE) call this from a different cookie scope
// than the zddc-server origin, so the elevation cookie can't reach
// here even when the user has it set. This is a coarse "is this
// email a root admin?" check, not a per-action authority decision —
// construct a synthetically-elevated Principal so the underlying
// admin check evaluates the admins: list as usual.
if email == "" || !zddc.IsAdmin(cfg.Root, zddc.Principal{Email: email, Elevated: true}) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

View file

@ -32,7 +32,7 @@ func TestServeAuthAdmin(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := requestWithEmail(http.MethodGet, AuthPathPrefix+"/admin", tc.email)
req := requestAsAdmin(http.MethodGet, AuthPathPrefix+"/admin", tc.email)
rec := httptest.NewRecorder()
ServeAuthAdmin(cfg, rec, req)
if rec.Code != tc.wantStatus {
@ -53,7 +53,7 @@ func TestServeAuthAdmin_NoZddcRootDeniesEverything(t *testing.T) {
cfg, _ := profileTestRoot(t, nil)
for _, email := range []string{"", "alice@example.com", "anyone@example.com"} {
req := requestWithEmail(http.MethodGet, AuthPathPrefix+"/admin", email)
req := requestAsAdmin(http.MethodGet, AuthPathPrefix+"/admin", email)
rec := httptest.NewRecorder()
ServeAuthAdmin(cfg, rec, req)
if rec.Code != http.StatusForbidden {

View file

@ -120,8 +120,7 @@ func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL str
//
// Admin escape hatches: root admins (IsAdmin) and subtree admins
// (IsSubtreeAdmin) get unconditional access — the cascade evaluator
// and the WORM mask do not see their requests at all. This matches
// the existing admin-bypass semantics in /.profile/zddc and is the
// and the WORM mask do not see their requests at all. This is the
// only way to mutate filed documents in Issued/Received.
//
// .zddc writes use the stricter CanEditZddc rule (strict-ancestor
@ -144,29 +143,30 @@ func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request,
}
email := EmailFromContext(r)
p := PrincipalFromContext(r)
// Admin bypass — root and subtree.
if zddc.IsAdmin(cfg.Root, email) {
// Admin bypass — root and subtree. Un-elevated admins fall through
// to the regular decider; the Principal's Elevated flag short-
// circuits both IsAdmin and IsSubtreeAdmin so we don't accidentally
// hand out WORM bypass to a user who hasn't asked for it.
if zddc.IsAdmin(cfg.Root, p) {
return true
}
if zddc.IsSubtreeAdmin(cfg.Root, probe, email) {
if zddc.IsSubtreeAdmin(cfg.Root, probe, p) {
return true
}
// .zddc writes: CanEditZddc enforces the strict-ancestor rule that
// prevents a subtree admin from elevating themselves by editing the
// .zddc that grants their authority. Non-admins fall through to the
// regular decider — they will be denied unless an explicit `a` verb
// is granted to a non-admin role at this path, which is unusual.
// .zddc that grants their authority. Non-admins (or un-elevated
// admins) fall through to the regular decider — they will be denied
// unless an explicit `a` verb is granted to a non-admin role at
// this path, which is unusual.
if filepath.Base(absPath) == ".zddc" {
zddcDir := filepath.Dir(absPath)
if zddc.CanEditZddc(cfg.Root, zddcDir, email) {
if zddc.CanEditZddc(cfg.Root, zddcDir, p) {
return true
}
// Non-admin .zddc writes go through the normal cascade with
// action=admin. Most deployments will have no acl.permissions
// entry granting `a`, so this denies; operators who want
// non-admin .zddc edits can grant `a` explicitly.
}
chain, err := zddc.EffectivePolicy(cfg.Root, probe)

View file

@ -69,6 +69,7 @@ func fileAPITestSetup(t *testing.T, dirs []string, seed map[string]string) (cfg
req.Header.Set(k, v)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
@ -166,6 +167,7 @@ func TestFileAPI_PutOversizeRejected(t *testing.T) {
body := bytes.Repeat([]byte("A"), 32)
req := httptest.NewRequest(http.MethodPut, "/Incoming/big.bin", bytes.NewReader(body))
ctx := context.WithValue(req.Context(), EmailKey, "alice@example.com")
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
@ -450,6 +452,7 @@ acl:
req.Header.Set(k, v)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
ctx = context.WithValue(ctx, DeciderKey, decider)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
@ -649,6 +652,7 @@ func TestFileAPI_StrictMode_AncestorDenyAbsolute(t *testing.T) {
req = httptest.NewRequest(method, target, nil)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
ctx = context.WithValue(ctx, DeciderKey, decider)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()

View file

@ -81,6 +81,7 @@ func formTestSetup(t *testing.T, zddcFiles map[string]string) (config.Config, fu
req = httptest.NewRequest(method, target, nil)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()

View file

@ -10,6 +10,7 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"log/slog"
)
@ -26,6 +27,23 @@ const EmailKey contextKey = "email"
// "swap internal evaluator for external OPA" plumbing change.
const DeciderKey contextKey = "policy-decider"
// ElevatedKey is the context key for the per-request elevation flag.
// Drives zddc.Principal{Elevated} for admin-authority checks. Set by
// ACLMiddleware according to the request's auth shape:
// - Bearer tokens are implicitly elevated (machine clients can't
// toggle a cookie; they're expected to act with the bearer's full
// authority).
// - Header-auth (browser) sessions elevate iff the request carries
// a `zddc-elevate=1` cookie. The cookie is set/cleared by the
// elevation toggle UI in the tool headers.
const ElevatedKey contextKey = "elevated"
// elevationCookieName is the cookie clients set to elevate their admin
// powers for header-auth (browser) sessions. Value "1" = elevated; any
// other value (or absent) = treat as non-admin even if the email is
// named in admin lists.
const elevationCookieName = "zddc-elevate"
// ACLMiddleware extracts the user email and stores it (along with the
// policy decider) in the request context. It does NOT enforce ACL
// itself — each handler performs its own ACL check via
@ -49,6 +67,7 @@ const DeciderKey contextKey = "policy-decider"
func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var email string
var elevated bool
if bearer := bearerToken(r); bearer != "" {
if tokens == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
@ -63,8 +82,20 @@ func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store
return
}
email = tok.Email
// Bearer-token callers (CLI tools, scripts, mirror clients)
// can't toggle a cookie — they're expected to operate with
// the bearer's full authority. Implicit elevation keeps the
// admin functions usable from the machine-client path.
elevated = true
} else {
email = r.Header.Get(cfg.EmailHeader)
// Browser sessions opt in to admin powers via the UI's
// elevation toggle, which sets a `zddc-elevate=1` cookie.
// Absent / any other value → treat as non-admin even when
// the email is named in admin lists.
if c, err := r.Cookie(elevationCookieName); err == nil && c.Value == "1" {
elevated = true
}
}
// DEBUG-level header dump for diagnosing proxy / SSO header
// passthrough. Off by default (LogLevel info); enable with
@ -79,6 +110,7 @@ func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store
"observed", email,
"headers", r.Header)
ctx := context.WithValue(r.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, elevated)
if decider != nil {
ctx = context.WithValue(ctx, DeciderKey, decider)
}
@ -116,6 +148,37 @@ func WithEmail(ctx context.Context, email string) context.Context {
return context.WithValue(ctx, EmailKey, email)
}
// ElevatedFromContext reports whether the request has opted into its
// admin powers. False for any request that wasn't tagged by
// ACLMiddleware (including tests that don't install it), so admin
// checks fail closed.
func ElevatedFromContext(r *http.Request) bool {
if v, ok := r.Context().Value(ElevatedKey).(bool); ok {
return v
}
return false
}
// WithElevation returns a context carrying the elevation flag under
// ElevatedKey. Test seam for the matching PrincipalFromContext lookup.
func WithElevation(ctx context.Context, elevated bool) context.Context {
return context.WithValue(ctx, ElevatedKey, elevated)
}
// PrincipalFromContext bundles the request's authenticated email plus
// its elevation flag into a zddc.Principal — the value type the admin
// functions (IsAdmin, IsSubtreeAdmin, CanEditZddc) consume. One call
// per admin-check site replaces the previous ad-hoc email argument
// AND the previous "did I remember to gate this?" review burden: the
// type system enforces the gate by requiring a Principal value, which
// can only come from ACLMiddleware-tagged contexts.
func PrincipalFromContext(r *http.Request) zddc.Principal {
return zddc.Principal{
Email: EmailFromContext(r),
Elevated: ElevatedFromContext(r),
}
}
// DeciderFromContext extracts the policy decider from the request
// context. Returns the internal decider as a fallback if none was
// installed — this matches the "no OPA configured" semantics and
@ -201,15 +264,22 @@ func AccessLogMiddleware(auditLogger *slog.Logger, next http.Handler) http.Handl
// Calculate duration
durationMs := int(time.Since(start).Milliseconds())
// Get email from context
// Get email + elevation from context. The `elevated` field
// records whether the caller had opted into admin powers for
// this request — sudo-style. Surfacing it in the audit stream
// lets forensics distinguish "admin acting as a normal user"
// from "admin exercising authority" when reviewing a
// destructive action.
email := EmailFromContext(r)
if email == "" {
email = "anonymous"
}
elevated := ElevatedFromContext(r)
args := []any{
"ts", start.Format(time.RFC3339),
"email", email,
"elevated", elevated,
"method", r.Method,
"path", requestedPath,
"status", wrapped.status,

View file

@ -34,9 +34,16 @@ func ServeProfile(cfg config.Config, ring *LogRing, idx *archive.Index, w http.R
sub = "/"
}
// Delegated to ServeZddc; that handler has its own hasAnyAdminScope gate.
if sub == "/zddc" || strings.HasPrefix(sub, "/zddc/") {
ServeZddc(cfg, w, r)
// (The /.profile/zddc/* namespace previously hosted a parallel
// REST API + form-rendered editor for .zddc files. Retired —
// the YAML/CodeMirror editor in browse + the generic file-API
// (PUT/DELETE /<path>/.zddc) cover the same surface. Old links
// to `<dir>/.zddc.html` are 302'd to `<dir>/?file=.zddc` in the
// top-level dispatcher. The /assets/ sub-path is still served
// — the profile page emits a <link> to its custom.css when an
// operator has placed one at root.)
if strings.HasPrefix(sub, "/assets/") {
serveProfileAssets(cfg, w, r)
return
}
@ -46,35 +53,35 @@ func ServeProfile(cfg config.Config, ring *LogRing, idx *archive.Index, w http.R
case "/", "":
serveProfilePage(cfg, w, r)
case "/access":
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, email))
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, PrincipalFromContext(r)))
case "/projects":
serveProfileProjectsCreate(cfg, w, r)
case "/whoami":
if !zddc.IsAdmin(cfg.Root, email) {
if !zddc.IsAdmin(cfg.Root, PrincipalFromContext(r)) {
http.NotFound(w, r)
return
}
serveProfileWhoami(cfg, email, w, r)
case "/config":
if !zddc.IsAdmin(cfg.Root, email) {
if !zddc.IsAdmin(cfg.Root, PrincipalFromContext(r)) {
http.NotFound(w, r)
return
}
serveProfileConfig(cfg, w, r)
case "/logs":
if !zddc.IsAdmin(cfg.Root, email) {
if !zddc.IsAdmin(cfg.Root, PrincipalFromContext(r)) {
http.NotFound(w, r)
return
}
serveProfileLogs(ring, w, r)
case "/effective-policy":
if !zddc.IsAdmin(cfg.Root, email) {
if !zddc.IsAdmin(cfg.Root, PrincipalFromContext(r)) {
http.NotFound(w, r)
return
}
serveProfileEffectivePolicy(cfg, w, r)
case "/reindex":
if !zddc.IsAdmin(cfg.Root, email) {
if !zddc.IsAdmin(cfg.Root, PrincipalFromContext(r)) {
http.NotFound(w, r)
return
}
@ -110,16 +117,32 @@ func serveProfileReindex(cfg config.Config, idx *archive.Index, email string, w
})
}
// treeEntry is one row in the AccessView's AdminSubtrees /
// EditableParentChoices lists. The profile page renders them inline;
// the create-project form derives its parent-selector from the
// EditableParentChoices subset.
type treeEntry struct {
Path string `json:"path"`
CanEdit bool `json:"can_edit"`
Title string `json:"title,omitempty"`
}
// AccessView is the data the profile page lazy-loads from /.profile/access
// after first paint. The HTML shell renders only Email/EmailHeader/
// IsSuperAdmin (all cheap); Projects + AdminSubtrees + HasAnyAdminScope come
// in via JS. EditableParentChoices is what the create-project form's
// parent-selector renders — derived from AdminSubtrees on the client.
//
// IsSuperAdmin and HasAnyAdminScope reflect EFFECTIVE authority — gated
// by elevation. CanElevate is the independent "do you have any admin
// grant ANYWHERE in the tree, regardless of elevation?" signal that the
// header elevation toggle reads to decide whether to show itself.
type AccessView struct {
Email string `json:"email"`
EmailHeader string `json:"email_header"`
IsSuperAdmin bool `json:"is_super_admin"`
HasAnyAdminScope bool `json:"has_any_admin_scope"`
CanElevate bool `json:"can_elevate"`
Projects []ProjectInfo `json:"projects"`
AdminSubtrees []treeEntry `json:"admin_subtrees"`
EditableParentChoices []treeEntry `json:"editable_parent_choices"`
@ -128,16 +151,23 @@ type AccessView struct {
// enumerateAccess builds an AccessView for the given caller. Used by the
// JSON endpoint at /.profile/access; the HTML page no longer calls this on
// the request hot path — it ships a shell first and the client fetches the
// view after first paint.
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, email string) AccessView {
// view after first paint. The principal carries elevation: an un-elevated
// admin reports IsSuperAdmin=false here, so the UI naturally renders the
// non-elevated view (no admin scaffolds shown) until the user opts in.
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal) AccessView {
view := AccessView{
Email: email,
Email: p.Email,
EmailHeader: cfg.EmailHeader,
IsSuperAdmin: zddc.IsAdmin(cfg.Root, email),
IsSuperAdmin: zddc.IsAdmin(cfg.Root, p),
}
view.Projects, _ = EnumerateProjects(ctx, decider, cfg, email)
view.AdminSubtrees = enumerateAdminSubtrees(cfg, email)
view.Projects, _ = EnumerateProjects(ctx, decider, cfg, p.Email)
view.AdminSubtrees = enumerateAdminSubtrees(cfg, p)
view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
// CanElevate is the elevation-INDEPENDENT discovery flag: "does
// this user have admin authority that they could opt into?"
// Drives the header elevation toggle's visibility — an un-
// elevated admin still needs to see the toggle they'd flip.
view.CanElevate = zddc.HasAnyAdminGrant(cfg.Root, p.Email)
for _, t := range view.AdminSubtrees {
if t.CanEdit {
view.EditableParentChoices = append(view.EditableParentChoices, t)
@ -149,12 +179,13 @@ func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Con
// enumerateAdminSubtrees lists every directory containing a .zddc that the
// caller can see as an admin (super-admin or subtree-admin). Each entry
// carries can_edit so the page can label read-only entries (the file that
// grants the user's own authority).
func enumerateAdminSubtrees(cfg config.Config, email string) []treeEntry {
// grants the user's own authority). Returns empty for an un-elevated
// principal — the elevation flag short-circuits each admin check below.
func enumerateAdminSubtrees(cfg config.Config, p zddc.Principal) []treeEntry {
dirs, _ := zddc.ScanZddcFiles(cfg.Root)
out := make([]treeEntry, 0, len(dirs))
for _, d := range dirs {
if !zddc.IsSubtreeAdmin(cfg.Root, d, email) && !zddc.IsAdmin(cfg.Root, email) {
if !zddc.IsSubtreeAdmin(cfg.Root, d, p) && !zddc.IsAdmin(cfg.Root, p) {
continue
}
var title string
@ -163,7 +194,7 @@ func enumerateAdminSubtrees(cfg config.Config, email string) []treeEntry {
}
out = append(out, treeEntry{
Path: urlPathOf(cfg.Root, d),
CanEdit: zddc.CanEditZddc(cfg.Root, d, email),
CanEdit: zddc.CanEditZddc(cfg.Root, d, p),
Title: title,
})
}

View file

@ -41,13 +41,26 @@ func profileTestRoot(t *testing.T, admins []string) (config.Config, *LogRing) {
}, NewLogRing(50)
}
// requestWithEmail builds a request whose context already carries email (as
// the real ACLMiddleware would inject) and whose path is path.
func requestWithEmail(method, path, email string) *http.Request {
// requestAsAdmin builds a test request whose context carries email
// AND Elevated=true — the wire shape ACLMiddleware would inject for
// a bearer-token caller or a browser session with the elevation
// cookie set. Name is the convention: every admin-action test should
// reach for THIS helper, so the call site visibly opts into admin
// authority. Tests that need to exercise the un-elevated path use
// requestAsUserMaybeElevated(method, path, email, false) explicitly —
// see the un-elevated negative tests in admin_test.go for that shape.
func requestAsAdmin(method, path, email string) *http.Request {
return requestAsUserMaybeElevated(method, path, email, true)
}
// requestAsUserMaybeElevated is the explicit form. Tests for the
// "un-elevated admin should fail closed" gate pass elevated=false.
func requestAsUserMaybeElevated(method, path, email string, elevated bool) *http.Request {
r := httptest.NewRequest(method, path, nil)
if email != "" {
r.Header.Set("X-Auth-Request-Email", email)
ctx := context.WithValue(r.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, elevated)
r = r.WithContext(ctx)
}
return r
@ -107,7 +120,7 @@ func TestServeProfileGateMatrix(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, tc.path, tc.email))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, tc.path, tc.email))
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body.String())
}
@ -118,7 +131,7 @@ func TestServeProfileGateMatrix(t *testing.T) {
func TestServeProfileWhoamiPayload(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
r := requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com")
r := requestAsAdmin(http.MethodGet, "/.profile/whoami", "alice@example.com")
r.Header.Set("X-Other-Header", "hi there")
ServeProfile(cfg, ring, nil, rec, r)
@ -159,7 +172,7 @@ func TestServeProfileConfigPayload(t *testing.T) {
cfg.CORSOrigins = []string{"https://zddc.varasys.io"}
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/config", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/config", "alice@example.com"))
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
@ -186,7 +199,7 @@ func TestServeProfileLogsPayload(t *testing.T) {
logger.Warn("second", "code", 42)
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/logs", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/logs", "alice@example.com"))
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
@ -213,7 +226,7 @@ func TestServeProfileLogsLevelFilter(t *testing.T) {
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec,
requestWithEmail(http.MethodGet, "/.profile/logs?level=warn", "alice@example.com"))
requestAsAdmin(http.MethodGet, "/.profile/logs?level=warn", "alice@example.com"))
var got []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
@ -283,7 +296,7 @@ func TestServeProfileHTMLLayered(t *testing.T) {
render := func(email string) string {
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/", email))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/", email))
if rec.Code != http.StatusOK {
t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String())
}
@ -377,7 +390,7 @@ func TestServeProfileHTMLLayered(t *testing.T) {
func TestServeProfileAccessJSON(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/access", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/access", "alice@example.com"))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
@ -419,7 +432,7 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
fetchAccess := func(email string) AccessView {
t.Helper()
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/access", email))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/access", email))
if rec.Code != http.StatusOK {
t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String())
}
@ -502,7 +515,7 @@ func TestServeProfileEffectivePolicy(t *testing.T) {
// Trace alice (allowed at the leaf).
rec := httptest.NewRecorder()
r := requestWithEmail(http.MethodGet,
r := requestAsAdmin(http.MethodGet,
"/.profile/effective-policy?path=/Closed-Project/&email=alice@mycompany.com",
"super@admin.com")
ServeProfile(cfg, ring, nil, rec, r)
@ -546,7 +559,7 @@ func TestServeProfileEffectivePolicy(t *testing.T) {
// Trace bob (not allow-listed; root has no broad allow either).
rec2 := httptest.NewRecorder()
r2 := requestWithEmail(http.MethodGet,
r2 := requestAsAdmin(http.MethodGet,
"/.profile/effective-policy?path=/Closed-Project/&email=bob@mycompany.com",
"super@admin.com")
ServeProfile(cfg, ring, nil, rec2, r2)
@ -604,7 +617,7 @@ func TestServeProfileEffectivePolicy_InheritFence(t *testing.T) {
// Trace a my-company user — fenced out at the leaf, despite root grant.
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec,
requestWithEmail(http.MethodGet,
requestAsAdmin(http.MethodGet,
"/.profile/effective-policy?path=/Vendor/&email=alice@mycompany.com",
"super@admin.com"))
if rec.Code != http.StatusOK {
@ -643,7 +656,7 @@ func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
zddc.InvalidateCache(cfg.Root)
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/", "alice@example.com"))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
@ -654,7 +667,7 @@ func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
// Per-resource gates remain.
rec = httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/whoami", "alice@example.com"))
if rec.Code != http.StatusNotFound {
t.Errorf("/.profile/whoami status = %d, want 404 (no admins configured)", rec.Code)
}
@ -676,7 +689,9 @@ func TestServeProfileProjectsCreate(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if email != "" {
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
}
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, req)
@ -744,7 +759,9 @@ func TestServeProfileProjectsCreate(t *testing.T) {
// Method other than POST is 405.
req := httptest.NewRequest(http.MethodGet, "/.profile/projects", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
ctx := context.WithValue(req.Context(), EmailKey, "root@example.com")
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec = httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, req)
if rec.Code != http.StatusMethodNotAllowed {
@ -765,7 +782,9 @@ func TestServeProfileProjectsCreateValidatesZddc(t *testing.T) {
body := `{"parent":"/", "name":"badproject", "acl":{"allow":["bad@@glob"], "deny":[]}}`
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
ctx := context.WithValue(req.Context(), EmailKey, "root@example.com")
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeProfile(cfg, NewLogRing(50), nil, rec, req)
@ -801,7 +820,9 @@ func TestSubtreeAdminCanCreateInScope(t *testing.T) {
body := `{"parent":"` + parent + `", "name":"` + name + `"}`
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
ctx := context.WithValue(req.Context(), EmailKey, "alice@example.com")
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeProfile(cfg, NewLogRing(50), nil, rec, req)
return rec.Code
@ -843,7 +864,7 @@ func TestServeProfileReindexPOST(t *testing.T) {
}
rec := httptest.NewRecorder()
req := requestWithEmail(http.MethodPost, "/.profile/reindex", "alice@example.com")
req := requestAsAdmin(http.MethodPost, "/.profile/reindex", "alice@example.com")
ServeProfile(cfg, ring, idx, rec, req)
if rec.Code != http.StatusOK {
@ -881,7 +902,7 @@ func TestAdminPathHardCut(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
for _, p := range []string{"/.admin/", "/.admin/whoami", "/.admin/zddc/edit?path=/"} {
rec := httptest.NewRecorder()
req := requestWithEmail(http.MethodGet, p, "alice@example.com")
req := requestAsAdmin(http.MethodGet, p, "alice@example.com")
// Calling ServeProfile directly with /.admin path: it should not match
// the /.profile prefix and so return 404. (The real-world path is
// dispatch() routing — covered in main_test.go.)

View file

@ -45,9 +45,9 @@ func serveProfilePage(cfg config.Config, w http.ResponseWriter, r *http.Request)
view := profileView{
Email: email,
EmailHeader: cfg.EmailHeader,
IsSuperAdmin: zddc.IsAdmin(cfg.Root, email),
IsSuperAdmin: zddc.IsAdmin(cfg.Root, PrincipalFromContext(r)),
ProfilePathPrefix: ProfilePathPrefix,
AssetsPathPrefix: zddcAssetsPathPrefix,
AssetsPathPrefix: profileAssetsPathPrefix,
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -404,7 +404,12 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
var html = '<ul class="bare">';
parents.forEach(function(p) {
var path = escText(p.path);
html += '<li><a href="' + escText(prefix) + '/zddc/edit?path=' + path + '">'
// Link to browse opening the .zddc in the YAML/CodeMirror
// editor (with .zddc-schema lint). Replaces the retired form-
// based editor at <prefix>/zddc/edit?path=; same data, one
// canonical edit surface.
var dirURL = path === '/' ? '/' : path + '/';
html += '<li><a href="' + dirURL + '?file=.zddc">'
+ '<code>' + path + '/.zddc</code></a>';
if (p.title) html += ' ' + escText(p.title);
html += '</li>';

View file

@ -10,6 +10,12 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// writeError is the JSON envelope used by admin-write endpoints to
// surface zddc.ValidateFile field errors back to the caller.
type writeError struct {
Errors []zddc.FieldError `json:"errors"`
}
// projectCreateRequest is the wire shape for POST /.profile/projects.
//
// All fields except parent and name are optional. The ACL/admins/title
@ -38,10 +44,11 @@ type projectCreateResponse struct {
// admin grant). Non-authorized callers receive 404 to keep this endpoint's
// existence hidden alongside the rest of the admin surface.
func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// Admin gate first so non-admins see 404 regardless of method, matching
// the rest of /.profile/'s existence-leakage policy.
email := EmailFromContext(r)
if !hasAnyAdminScope(cfg.Root, email) {
// Admin gate first so non-admins (AND un-elevated admins) see 404
// regardless of method, matching the rest of /.profile/'s existence-
// leakage policy.
p := PrincipalFromContext(r)
if !hasAnyAdminScope(cfg.Root, p) {
http.NotFound(w, r)
return
}
@ -75,7 +82,7 @@ func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *htt
}
newDir := filepath.Join(parentAbs, req.Name)
if !zddc.CanEditZddc(cfg.Root, newDir, email) {
if !zddc.CanEditZddc(cfg.Root, newDir, p) {
http.NotFound(w, r)
return
}

View file

@ -2,9 +2,7 @@ package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
@ -14,10 +12,14 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// ProjectInfo is a single entry in the project list response.
// ProjectInfo is a single entry in the EnumerateProjects result.
// Used by the profile page (server-rendered) to list projects the
// caller can reach. NOT the wire shape for GET / Accept: application/json
// anymore — that endpoint now returns listing.FileInfo entries with
// per-entry Title, matching every other directory listing.
//
// Title is read from the project's own .zddc file (its `title:` field) when
// present; absent or empty means the landing page shows just the directory
// present; absent or empty means the profile page shows just the directory
// name. omitempty keeps the JSON small for projects without titles.
type ProjectInfo struct {
Name string `json:"name"`
@ -25,43 +27,9 @@ type ProjectInfo struct {
Title string `json:"title,omitempty"`
}
// ServeProjectList handles GET / with Accept: application/json.
// It returns all top-level directories under cfg.Root that the requesting
// user has access to, as a JSON array of ProjectInfo.
//
// Response carries a content-hash ETag. The landing page polls this
// endpoint on every paint, and the response (a small JSON array of
// project names + URLs the caller can reach) rarely changes between
// polls, so 304s save a meaningful amount of cumulative bandwidth.
// The hash is computed from the actual response body, so it tolerates
// unreliable filesystem watching.
func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request) {
projects, err := EnumerateProjects(r.Context(), DeciderFromContext(r), cfg, EmailFromContext(r))
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(projects)
if err != nil {
slog.Error("encoding project list", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
etag := `"` + listingETag(body) + `"`
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
}
// EnumerateProjects returns the visible top-level projects for the given
// caller, reusing the same access logic as ServeProjectList. Exported so
// the profile page can render the same list server-side without an HTTP
// round-trip. A nil decider falls back to the internal Go evaluator.
// caller. Exported for the profile page's server-rendered project list.
// A nil decider falls back to the internal Go evaluator.
func EnumerateProjects(ctx context.Context, decider policy.Decider, cfg config.Config, email string) ([]ProjectInfo, error) {
if decider == nil {
decider = &policy.InternalDecider{}

View file

@ -1,117 +0,0 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// TestServeProjectListFiltersHiddenAndScaffolding asserts the project list
// excludes both '.'-prefixed entries (the long-standing rule, e.g. .devshell)
// AND '_'-prefixed entries (operator scaffolding like install.zip's
// _template/ that's reachable by direct URL but should not clutter the
// project picker).
func TestServeProjectListFiltersHiddenAndScaffolding(t *testing.T) {
root := t.TempDir()
for _, name := range []string{
"Project-A",
"Project-B",
".devshell", // dot-prefixed dir — must be excluded
"_template", // underscore scaffolding — must be excluded
"_archive",
} {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", name, err)
}
}
// A loose .zddc that allows everyone, so ACL doesn't interfere with
// what we're actually testing (the prefix filter).
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeProjectList(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
}
var got []map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
}
want := map[string]bool{"Project-A": true, "Project-B": true}
if len(got) != len(want) {
t.Fatalf("got %d projects (%+v), want %d (%v)", len(got), got, len(want), want)
}
for _, p := range got {
if !want[p["name"]] {
t.Errorf("unexpected project in list: %q", p["name"])
}
}
}
// TestServeProjectListIncludesTitleFromPerProjectZddc verifies a project's own
// .zddc `title:` field surfaces in the JSON response; projects without it (or
// without any .zddc) come back with an empty/absent title.
func TestServeProjectListIncludesTitleFromPerProjectZddc(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"176109", "197072"} {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", name, err)
}
}
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "176109", ".zddc"),
[]byte("title: \"Greenfield Substation\"\n"), 0o644); err != nil {
t.Fatalf("write project .zddc: %v", err)
}
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeProjectList(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
}
var got []ProjectInfo
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
}
titles := map[string]string{}
for _, p := range got {
titles[p.Name] = p.Title
}
if titles["176109"] != "Greenfield Substation" {
t.Errorf("176109 title = %q, want %q", titles["176109"], "Greenfield Substation")
}
if titles["197072"] != "" {
t.Errorf("197072 title = %q, want empty", titles["197072"])
}
}

View file

@ -105,6 +105,7 @@ func tableTestSetup(t *testing.T, rows map[string]string, zddcFiles map[string]s
do := func(method, target, email string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, bytes.NewReader(nil))
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
@ -250,6 +251,7 @@ func archivePartyTestSetup(t *testing.T, partyZddcExtras string) (string, func(m
do := func(method, target, email string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, bytes.NewReader(nil))
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
tr := RecognizeTableRequest(cfg.Root, method, target)

View file

@ -269,7 +269,7 @@ a:hover {
}
/* Subdued / de-emphasized variant.
Used on the "Add Local Directory" button when a tool is operating
Used on the "Use Local Directory" button when a tool is operating
in server (online) mode — the local-dir affordance is still
available but visually quieter, since the typical user already
has the directory loaded from the server. */
@ -331,6 +331,11 @@ a:hover {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
/* Let the left / right groups wrap to a second row at narrow
viewports rather than overflowing the viewport edge. row-gap
gives a small breathing strip when wrapped. */
flex-wrap: wrap;
row-gap: 0.3rem;
}
/* Left and right groups inside .app-header. Both flex-row so their
@ -342,16 +347,35 @@ a:hover {
display: flex;
align-items: center;
gap: 0.75rem;
/* Allow the title to shrink (and ellipsize) before the action
buttons get pushed off-screen at narrow viewports. */
min-width: 0;
flex-wrap: wrap;
row-gap: 0.3rem;
}
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
/* Title group (title + build label). Made shrinkable so narrow
viewports don't push the action buttons out of view; the title
itself ellipsizes via the rule below. */
.header-title-group {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
flex-shrink: 1;
}
/* Tool name inside the header. Renders in the display serif so the
tool's identity reads as a document title, not a UI label. */
tool's identity reads as a document title, not a UI label.
overflow + ellipsis on min-width:0 lets the title compress
gracefully when there's no room. */
.app-header__title {
font-family: var(--font-display);
font-size: 18px;
@ -359,6 +383,9 @@ a:hover {
color: var(--text);
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* Brand logo — sits left of the title in every tool's app-header.
@ -809,6 +836,54 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; }
}
/* shared/elevation.css — admin-elevation toggle in the tool header.
Renders only for users with admin scope (handled by elevation.js;
the placeholder is `.hidden` by default). When visible, sits left
of the theme button — sudo-style affordance for opting into admin
powers. */
.elevation-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--text-muted);
user-select: none;
cursor: pointer;
padding: 0.15rem 0.45rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.elevation-toggle:hover {
background: var(--bg-hover);
border-color: var(--border-dark);
}
.elevation-toggle input[type="checkbox"] {
margin: 0;
cursor: pointer;
accent-color: var(--danger);
}
.elevation-toggle__label {
cursor: pointer;
letter-spacing: 0.02em;
}
/* Active state — when elevation is ON, the toggle reads as "armed"
so the user can't miss that admin powers are currently live.
:has(:checked) lets us style the wrapper based on the inner
checkbox without JS. */
.elevation-toggle:has(input:checked) {
background: rgba(220, 53, 69, 0.12);
border-color: var(--danger);
color: var(--danger);
font-weight: 600;
}
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
Sits as a sibling immediately under .app-header (mounted by JS).
Rendered only in online mode when a project segment is in the URL. */
@ -1300,10 +1375,16 @@ 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-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-14 17:07:00 · a62960b-dirty</span></span>
</div>
</div>
<div class="header-right">
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div>
@ -2135,14 +2216,34 @@ body.help-open .app-header {
// Top-level helpers
// -----------------------------------------------------------------
// Strip a trailing tool .html (e.g. classifier.html) from a path
// to land on the "directory the tool was opened in".
// Resolve "the directory the tool was opened in" for the current
// page URL. Two URL shapes serve a tool:
//
// /…/<tool>.html — file URL; strip the trailing filename.
// /…/<dir>/ — trailing-slash directory URL; keep it.
// /…/<dir> — bare-directory URL served by the
// cascade's `default_tool` (e.g.
// archive/<party>/mdl serves the tables
// tool). Treat as the directory itself
// and append the missing slash.
//
// Discrimination is "does the last segment contain a dot?" — a dot
// is a reliable proxy for "looks like a file with an extension"
// since neither directory names nor default_tool paths contain
// them in this system.
function pathToDir(pathname) {
if (!pathname) return '/';
if (pathname.endsWith('/')) return pathname;
var slash = pathname.lastIndexOf('/');
var lastSeg = slash >= 0 ? pathname.substring(slash + 1) : pathname;
if (lastSeg.indexOf('.') !== -1) {
// Has an extension → looks like a file URL → strip the
// filename to land on the parent directory.
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
}
// No extension → the URL IS the directory; just close it.
return pathname + '/';
}
// Probe the server-mode root for the current page. Returns:
//
@ -2759,6 +2860,110 @@ body.help-open .app-header {
}
}());
// shared/elevation.js — admin elevation toggle.
//
// Sudo-style model: admins behave as normal users by default; clicking
// the header toggle elevates the session so admin escape hatches (WORM
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
// State is carried in a `zddc-elevate=1` cookie that the server reads
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
// Only renders the toggle when /.profile/access reports the caller has
// some admin scope — a non-admin sees nothing, which keeps the chrome
// quiet for the common case. The toggle fades in once access loads so
// non-admins never even see the affordance flash.
//
// Click flow: set/clear the cookie, then reload the page so the server
// sees the new state on the next render. The reload is intentional —
// admin scaffolds in tool HTML are server-rendered for some tools, so
// a soft state flip on the client alone wouldn't reach those.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.elevation) return;
var COOKIE_NAME = 'zddc-elevate';
function isElevated() {
var parts = document.cookie.split(';');
for (var i = 0; i < parts.length; i++) {
var kv = parts[i].trim().split('=');
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
}
return false;
}
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
// shapes. Max-Age caps the elevation window so a forgotten
// tab doesn't leave admin powers active indefinitely (sudo's
// 5-minute precedent informs the number — 30 minutes is a
// reasonable trade between annoyance and exposure).
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
} else {
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
}
}
async function fetchAccess() {
try {
var resp = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
function render(host, elevated) {
host.classList.remove('hidden');
host.innerHTML =
'<input type="checkbox" id="elevation-checkbox"'
+ (elevated ? ' checked' : '') + '>'
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
+ 'Admin</label>';
var cb = host.querySelector('#elevation-checkbox');
cb.addEventListener('change', function () {
setElevated(cb.checked);
// Hard reload so server-rendered admin surfaces (profile
// page scaffolds, hidden-entry listings) catch up. URL
// and scroll state are preserved by the browser's normal
// back-forward cache rules.
window.location.reload();
});
}
async function init() {
var host = document.getElementById('elevation-toggle');
if (!host) return; // tool doesn't include the slot yet — no-op
var access = await fetchAccess();
if (!access) return; // anonymous / endpoint missing — no-op
// Surface ONLY for users who have admin authority somewhere.
// /.profile/access ships `can_elevate` as an elevation-
// INDEPENDENT signal — true for any user named in any admin
// list, regardless of current cookie state. The other flags
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
// authority and would be false for an un-elevated admin
// who hasn't toggled yet — so we can't gate on those.
if (!access.can_elevate) return;
render(host, isElevated());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})();
// mode.js — picks table-mode vs form-mode at boot time and unhides the
// matching container. Both apps (tablesApp, formApp) ship in the same
// bundle but each only paints when its container is visible.
@ -2985,10 +3190,20 @@ body.help-open .app-header {
}
function tableNameFromUrl(pathname) {
// /<dir>/.../<rowsdir>/table.html → name is the rows-dir's
// basename.
const m = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
return m ? m[1] : null;
// Two URL shapes resolve to a table page:
// Form A — /<…>/<rowsdir>/table.html (legacy/explicit
// entry-point; the tool was opened via the
// literal file URL).
// Form B — /<…>/<rowsdir> or /<…>/<rowsdir>/ (served
// by the cascade's `default_tool: tables` at
// archive/<party>/mdl; the URL is the directory
// itself, no trailing filename).
// In both cases the table name is the rows-directory basename.
const a = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
if (a) return a[1];
const trimmed = String(pathname || '').replace(/\/$/, '');
const b = trimmed.match(/\/([^\/]+)$/);
return b ? b[1] : null;
}
function stripDotSlash(p) {
@ -3066,11 +3281,13 @@ body.help-open .app-header {
return rows;
}
// Re-edit URL for one row. Page is at /<dir>/table.html; row file
// lives at /<dir>/<basename>.yaml; form re-edit URL is
// /<dir>/<basename>.yaml.html — same directory.
// Re-edit URL for one row. The page directory is the same
// directory the rows live in, regardless of which URL shape
// (Form A `…/table.html` vs Form B bare `…/<rowsdir>`) we were
// opened with — see tableNameFromUrl.
function rowEditUrl(rowFileName) {
const pageDir = location.pathname.replace(/\/table\.html$/, '/');
let pageDir = location.pathname.replace(/\/table\.html$/, '/');
if (!pageDir.endsWith('/')) pageDir += '/';
return pageDir + rowFileName + '.html';
}

View file

@ -1,70 +0,0 @@
package handler
import (
"net/http"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// profileCustomCSSName is the preferred on-disk filename for operator-supplied
// profile / editor theming. The legacy `.admin.css` is honored as a fallback
// so an operator who already deployed the older name does not see their
// styling vanish on upgrade; new deployments should use the `.profile.css`
// name.
const (
profileCustomCSSName = ".profile.css"
adminCustomCSSName = ".admin.css" // legacy fallback
)
// hasCustomProfileCSS reports whether <fsRoot>/.profile.css (or the legacy
// .admin.css) exists. The editor and profile templates use this to decide
// whether to inject the <link> tag.
func hasCustomProfileCSS(fsRoot string) bool {
if _, err := os.Stat(filepath.Join(fsRoot, profileCustomCSSName)); err == nil {
return true
}
if _, err := os.Stat(filepath.Join(fsRoot, adminCustomCSSName)); err == nil {
return true
}
return false
}
// zddcAssetsPathPrefix is the URL prefix for admin-only static assets.
// They sit under /.profile/zddc/assets/ rather than /.profile/assets/ so
// they share the editor's broader auth gate (subtree-or-super-admin)
// instead of /.profile/'s super-admin-only diagnostics gate — otherwise a
// subtree admin would 404 on the custom CSS link emitted by the editor.
const zddcAssetsPathPrefix = ZddcProfilePathPrefix + "/assets"
// serveZddcAssets handles /.profile/zddc/assets/<file>. V1 only ships
// `custom.css` (passthrough of <root>/.profile.css when present, falling
// back to <root>/.admin.css); other paths return 404 so we don't
// accidentally expose arbitrary files. hasAnyAdminScope has already gated
// the request via ServeZddc.
func serveZddcAssets(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
rest := strings.TrimPrefix(r.URL.Path, zddcAssetsPathPrefix+"/")
switch rest {
case "custom.css":
path := filepath.Join(cfg.Root, profileCustomCSSName)
if fi, err := os.Stat(path); err != nil || fi.IsDir() {
path = filepath.Join(cfg.Root, adminCustomCSSName)
if fi, err := os.Stat(path); err != nil || fi.IsDir() {
http.NotFound(w, r)
return
}
}
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, path)
default:
http.NotFound(w, r)
}
}

View file

@ -1,503 +0,0 @@
package handler
import (
"html/template"
"net/http"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// editorView is the data passed to the editor template. Field naming is
// kept short for template ergonomics.
type editorView struct {
Path string
IsRoot bool
CanEdit bool
Exists bool
Email string
HasCustomCSS bool
File zddc.ZddcFile
EffectiveChain []chainEntry
AppsRows []appsRow
ProfilePathPrefix string // /.profile
AssetsPathPrefix string // /.profile/zddc/assets
}
// appsRow renders one line of the Apps section: the apps key (default or
// app name), its current value at THIS level (may be empty), and the
// preview of how it resolves once the cascade is applied.
type appsRow struct {
Key string // "default" or canonical app name
Value string // current spec at this .zddc level (empty = inherits)
ResolvesTo string // human-readable preview line
}
// serveZddcEditor renders the form-based .zddc editor at
// GET /.profile/zddc/edit?path=<dir>. The form posts JSON back to
// /.profile/zddc?path=<dir>; the inline JS shim handles dynamic-row
// add/remove and surfaces field errors from the JSON response.
func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path"))
if err != nil {
http.NotFound(w, r)
return
}
renderZddcEditor(cfg, w, r, abs)
}
// ServeZddcEditorAtPath is the per-directory entry to the editor. The
// dispatcher routes <dir>/.zddc.html requests here; the directory is
// derived from the URL path (parent of the .zddc.html leaf) rather
// than from a query parameter.
//
// Permission gate: the user must have an admin authority somewhere
// in the tree (same gate as the /.profile/zddc namespace). A non-
// admin sees 404 — no leak that an editor would otherwise be
// available. Within the editor, CanEditZddc decides whether the form
// is interactive or read-only at THIS specific .zddc; non-editors
// can still inspect the cascade if they have any admin scope.
func ServeZddcEditorAtPath(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
email := EmailFromContext(r)
if !hasAnyAdminScope(cfg.Root, email) {
http.NotFound(w, r)
return
}
// URL is <dir>/.zddc.html (or "/.zddc.html" for the deployment
// root). Strip the leaf to get the directory.
urlPath := strings.TrimSuffix(r.URL.Path, "/")
leafPath := "/" + ZddcEditorBasename
if !strings.HasSuffix(urlPath, leafPath) {
http.NotFound(w, r)
return
}
dirURL := strings.TrimSuffix(urlPath, leafPath)
if dirURL == "" {
dirURL = "/"
}
abs, err := resolvePath(cfg.Root, dirURL)
if err != nil {
http.NotFound(w, r)
return
}
// The directory must exist on disk; the per-path editor URL is a
// view onto an existing tree position, not a way to materialise
// arbitrary new directories. (The /.profile editor accepts a
// missing dir for the legacy path-as-query workflow.)
if info, err := os.Stat(abs); err != nil || !info.IsDir() {
http.NotFound(w, r)
return
}
renderZddcEditor(cfg, w, r, abs)
}
// ZddcEditorBasename is the URL leaf that the dispatcher recognises as
// a per-directory editor request. The dot-prefix guard carves this one
// segment out so the editor reaches the handler.
const ZddcEditorBasename = ".zddc.html"
// IsZddcEditorRequest reports whether urlPath ends with the editor's
// virtual basename. Used by the dispatcher to route the request to
// ServeZddcEditorAtPath ahead of the dot-prefix guard.
func IsZddcEditorRequest(urlPath string) bool {
clean := strings.TrimSuffix(urlPath, "/")
return strings.HasSuffix(clean, "/"+ZddcEditorBasename) ||
clean == "/"+ZddcEditorBasename
}
// renderZddcEditor renders the editor template against the .zddc at
// abs (which may not exist on disk yet). Shared between the
// /.profile/zddc/edit?path= entry and the per-directory <dir>/.zddc.html
// entry.
func renderZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request, abs string) {
email := EmailFromContext(r)
zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc"))
if err != nil {
http.Error(w, "Cannot parse existing .zddc: "+err.Error(), http.StatusBadRequest)
return
}
exists := false
if _, err := os.Stat(filepath.Join(abs, ".zddc")); err == nil {
exists = true
}
chain, _ := zddc.EffectivePolicy(cfg.Root, abs)
dirs := chainDirs(cfg.Root, abs)
entries := make([]chainEntry, 0, len(chain.Levels))
for i, level := range chain.Levels {
levelDir := dirs[i]
levelExists := false
if _, err := os.Stat(filepath.Join(levelDir, ".zddc")); err == nil {
levelExists = true
}
entries = append(entries, chainEntry{
Dir: urlPathOf(cfg.Root, levelDir),
Exists: levelExists,
Title: level.Title,
ACL: level.ACL,
Admins: level.Admins,
})
}
// Apps rows: for default + each canonical app, show the current value at
// THIS level (zf.Apps[key]) and the resolved preview given the cascade.
// Default key first, then canonical apps in declared order.
keys := append([]string{zddc.AppsDefaultKey}, zddc.AppNames...)
rows := make([]appsRow, 0, len(keys))
for _, k := range keys {
row := appsRow{Key: k, Value: zf.Apps[k]}
if k == zddc.AppsDefaultKey {
// "default" doesn't resolve to a single URL on its own — it's
// the baseline. Render a brief description.
if row.Value == "" {
row.ResolvesTo = "(unset — apps fall back to canonical " + apps.DefaultUpstreamReleases + " + " + apps.DefaultChannel + ")"
} else {
row.ResolvesTo = "baseline for any app not overridden below"
}
} else {
row.ResolvesTo = apps.PreviewLine(chain, k, cfg.Root, abs)
}
rows = append(rows, row)
}
view := editorView{
Path: urlPathOf(cfg.Root, abs),
IsRoot: abs == cfg.Root,
CanEdit: zddc.CanEditZddc(cfg.Root, abs, email),
Exists: exists,
Email: email,
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
File: zf,
EffectiveChain: entries,
AppsRows: rows,
ProfilePathPrefix: ProfilePathPrefix,
AssetsPathPrefix: zddcAssetsPathPrefix,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := editorTemplate.Execute(w, view); err != nil {
// Headers may already be flushed; best effort.
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// editorTemplate is the html/template body for the editor page.
//
// Style choices:
// - inline CSS uses the same custom-property naming as shared/base.css
// so a future server-side merge with shared/base.css remains trivial.
// - inline JS is one IIFE, ~80 lines, handling: add/remove row,
// collect-into-JSON-on-submit, render server-side field errors.
// - the form falls back to a plain HTTP POST (urlencoded) without JS;
// a tiny same-handler endpoint accepts urlencoded too. (V1: JS only;
// no-JS fallback is documented as a TODO in the file header.)
var editorTemplate = template.Must(template.New("editor").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>.zddc editor {{ .Path }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #fff; --bg-alt: #f7f7f8; --text: #222; --muted: #666;
--border: #d0d4dc; --primary: #2563eb; --primary-bg: #eff6ff;
--danger: #b00020; --warn: #b15c00; --ok: #0a7d2c;
--radius: 4px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1c1f; --bg-alt: #23262b; --text: #e8e8ea; --muted: #a0a4ad;
--border: #353941; --primary: #60a5fa; --primary-bg: #1e293b;
--danger: #ff7080; --warn: #f5b056; --ok: #4ad27c;
}
}
body { font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 1.5rem; color: var(--text); background: var(--bg); max-width: 980px; }
h1 { margin: 0 0 .25rem; font-size: 1.4rem; }
h2 { margin: 1.5rem 0 .25rem; font-size: 1.05rem; }
.muted { color: var(--muted); }
.breadcrumb { color: var(--muted); margin-bottom: 1rem; }
.breadcrumb a { color: var(--primary); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.ro-banner { background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: .6rem .9rem; margin-bottom: 1rem; }
.ro-banner.read-only { border-color: var(--warn); }
.card { background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.1rem; margin-bottom: 1rem; }
.card h2 { margin-top: 0; }
.card .help { color: var(--muted); font-size: .9em; margin: .3rem 0 .6rem; }
label { display: block; margin-bottom: .5rem; }
input[type="text"] { width: 100%; max-width: 32rem; padding: .35rem .5rem; font: inherit; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); box-sizing: border-box; }
input[type="text"]:focus { outline: 2px solid var(--primary); outline-offset: -1px; }
.row { display: flex; gap: .5rem; align-items: center; margin-bottom: .35rem; }
.row input[type="text"] { flex: 1; max-width: none; }
.row .err { color: var(--danger); font-size: .85em; margin-left: .5rem; }
.apps-table { width: 100%; border-collapse: collapse; margin-top: .5rem; }
.apps-table th { text-align: left; font-weight: 600; padding: .35rem .5rem; border-bottom: 1px solid var(--border); color: var(--muted); font-size: .85em; }
.apps-table td { padding: .3rem .5rem; vertical-align: middle; border-bottom: 1px solid var(--border); }
.apps-table td.k { width: 8em; white-space: nowrap; }
.apps-table td.k code { font-weight: 600; }
.apps-table td.v input { max-width: none; width: 100%; }
.apps-table td.v .err { color: var(--danger); font-size: .85em; display: block; margin-top: .15rem; }
.apps-table td.r { font-size: .85em; word-break: break-all; }
button { font: inherit; padding: .35rem .85rem; cursor: pointer; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: var(--radius); }
button:hover { background: var(--primary-bg); }
button.primary { background: var(--primary); color: white; border-color: var(--primary); }
button.primary:hover { filter: brightness(1.1); }
button.danger { color: var(--danger); border-color: var(--danger); }
button.danger:hover { background: rgba(176, 0, 32, 0.06); }
.actions { display: flex; gap: .5rem; align-items: center; margin-top: 1.2rem; }
.actions .spacer { flex: 1; }
.chain { font-size: .9em; }
.chain details { margin-bottom: .25rem; }
.chain summary { cursor: pointer; }
.chain pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: .5rem .7rem; margin: .35rem 0 .5rem; overflow-x: auto; }
.ok-banner { background: var(--primary-bg); border: 1px solid var(--primary); border-radius: var(--radius); padding: .55rem .85rem; margin-bottom: 1rem; color: var(--text); }
fieldset[disabled] input, fieldset[disabled] button { opacity: .55; cursor: not-allowed; }
code { background: var(--bg); padding: 0 .25rem; border-radius: 2px; font-size: 12px; border: 1px solid var(--border); }
</style>
{{ if .HasCustomCSS }}<link rel="stylesheet" href="{{ .AssetsPathPrefix }}/custom.css">{{ end }}
</head>
<body>
<div class="breadcrumb">
<a href="{{ .ProfilePathPrefix }}/">&larr; profile</a> &nbsp;/&nbsp;
<a href="{{ .ProfilePathPrefix }}/zddc/edit?path=/">root</a>{{ if not .IsRoot }} &nbsp;/&nbsp; <span>{{ .Path }}</span>{{ end }}
</div>
<h1>.zddc editor</h1>
<p class="muted">{{ if .IsRoot }}Editing the root <code>/.zddc</code>.{{ else }}Editing <code>{{ .Path }}/.zddc</code>.{{ end }} You are signed in as <code>{{ .Email }}</code>.</p>
{{ if not .CanEdit }}
<div class="ro-banner read-only"><strong>Read-only.</strong> You can view this file's contents and the inherited rules below, but you do not have permission to edit it. Subtree admins cannot edit the .zddc file that grants their own authority only an admin from a higher level can.</div>
{{ end }}
<div id="ok-banner" class="ok-banner" hidden>Saved.</div>
<form id="editor" autocomplete="off">
<fieldset {{ if not .CanEdit }}disabled{{ end }} style="border:0; padding:0; margin:0;">
<section class="card">
<h2>Title</h2>
<p class="help">Surfaced on the project picker for this folder. Optional projects without a title show their directory name.</p>
<input type="text" name="title" id="f-title" maxlength="200" value="{{ .File.Title }}">
</section>
<section class="card">
<h2>ACL Allow</h2>
<p class="help">Email-glob patterns for users granted access here. Examples: <code>*@example.com</code>, <code>alice@*</code>, <code>alice@example.com</code>. <code>*</code> matches any non-empty email but does not cross the <code>@</code> boundary.</p>
<div class="list" data-field="acl.allow">
{{ range $i, $v := .File.ACL.Allow }}<div class="row"><input type="text" data-field="acl.allow[{{ $i }}]" value="{{ $v }}"><button type="button" class="del"></button><span class="err"></span></div>{{ end }}
</div>
<button type="button" class="add" data-target="acl.allow">+ Add allow rule</button>
</section>
<section class="card">
<h2>ACL Deny</h2>
<p class="help">Deny is checked first; a parent allow cannot override a deeper deny. Same glob syntax as Allow.</p>
<div class="list" data-field="acl.deny">
{{ range $i, $v := .File.ACL.Deny }}<div class="row"><input type="text" data-field="acl.deny[{{ $i }}]" value="{{ $v }}"><button type="button" class="del"></button><span class="err"></span></div>{{ end }}
</div>
<button type="button" class="add" data-target="acl.deny">+ Add deny rule</button>
</section>
<section class="card">
<h2>{{ if .IsRoot }}Super-admins (bootstrap){{ else }}Subtree admins of {{ .Path }}{{ end }}</h2>
<p class="help">
{{ if .IsRoot }}Anyone here is an unrestricted admin of the entire server. They can edit any <code>.zddc</code> file, including this one. The very first super-admin is created by hand-editing this file at server install time. <strong>You cannot remove yourself</strong> from this list.
{{ else }}Anyone here can edit <code>.zddc</code> files anywhere <em>below</em> this directory. They <strong>cannot</strong> edit this file (where their authority comes from), so they cannot remove their delegator or add peers at their own level.
{{ end }}
</p>
<div class="list" data-field="admins">
{{ range $i, $v := .File.Admins }}<div class="row"><input type="text" data-field="admins[{{ $i }}]" value="{{ $v }}"><button type="button" class="del"></button><span class="err"></span></div>{{ end }}
</div>
<button type="button" class="add" data-target="admins">+ Add admin</button>
</section>
<section class="card">
<h2>Apps (tool HTML sources)</h2>
<p class="help">
Override which build of each tool the server serves at this directory and below.
Spec is one of: <code>stable</code> / <code>beta</code> / <code>alpha</code>,
<code>v0.0.4</code> / <code>v0.0</code> / <code>v0</code> (canonical upstream),
<code>https://my-mirror/releases</code> (URL prefix — composes with channel from <code>default</code>),
<code>https://my-mirror/releases:beta</code> (URL prefix + channel),
<code>:beta</code> (channel-only override of <code>default</code>'s URL),
<code>https://my-fork/archive.html</code> (terminal full URL),
<code>./local.html</code> or <code>/abs/path.html</code> (terminal local file).
Leave any row blank to inherit from a parent <code>.zddc</code> file.
The <code>default</code> row provides the baseline URL prefix and channel for any app not overridden per-name.
</p>
<p class="help muted">
Per-request override: any user can append <code>?v=&lt;spec&gt;</code> to a tool URL (e.g. <code>?v=beta</code>, <code>?v=v0.0.4</code>, <code>?v=:alpha</code>) to ask for a different build for one request. <strong>Security:</strong> <code>?v=</code> serves only versions already in the cache (<code>&lt;ZDDC_ROOT&gt;/_app/</code>); cache misses return 404 so users can't trigger arbitrary upstream fetches. Local-path specs are also rejected from <code>?v=</code>.
</p>
<table class="apps-table">
<thead><tr><th class="k">Key</th><th class="v">Value</th><th class="r">Resolves to</th></tr></thead>
<tbody>
{{ range .AppsRows }}<tr>
<td class="k"><code>{{ .Key }}</code></td>
<td class="v"><input type="text" data-apps-key="{{ .Key }}" value="{{ .Value }}" placeholder="(inherit)"><span class="err"></span></td>
<td class="r"><span class="muted">{{ .ResolvesTo }}</span></td>
</tr>{{ end }}
</tbody>
</table>
<p class="help muted">The <em>Resolves to</em> column reflects the saved state of the cascade save and reload to see how edits compose.</p>
</section>
<section class="card chain">
<details {{ if not .Exists }}open{{ end }}>
<summary>Effective chain (inherited rules)</summary>
{{ range .EffectiveChain }}<details><summary><code>{{ .Dir }}/.zddc</code> {{ if not .Exists }}<span class="muted">(no file at this level)</span>{{ end }}</summary><pre>title: {{ .Title }}
allow: {{ range .ACL.Allow }}{{ . }} {{ end }}
deny: {{ range .ACL.Deny }}{{ . }} {{ end }}
admins:{{ range .Admins }} {{ . }}{{ end }}</pre></details>{{ end }}
</details>
</section>
<div class="actions">
<button type="submit" class="primary" id="save">Save</button>
<button type="button" id="del" class="danger" {{ if .IsRoot }}disabled title="Cannot delete root .zddc"{{ end }}>Delete file</button>
<span class="spacer"></span>
<a href="?path={{ .Path }}">Cancel / refresh</a>
</div>
</fieldset>
</form>
<script>
(function() {
var path = {{ .Path }};
var canEdit = {{ .CanEdit }};
var isRoot = {{ .IsRoot }};
var apiURL = "{{ .ProfilePathPrefix }}/zddc?path=" + encodeURIComponent(path);
function rowFor(field, value) {
var div = document.createElement("div");
div.className = "row";
var input = document.createElement("input");
input.type = "text";
input.dataset.field = field;
input.value = value || "";
var del = document.createElement("button");
del.type = "button"; del.className = "del"; del.textContent = "";
var err = document.createElement("span");
err.className = "err";
div.appendChild(input); div.appendChild(del); div.appendChild(err);
return div;
}
document.querySelectorAll("button.add").forEach(function(btn) {
btn.addEventListener("click", function() {
var field = btn.dataset.target;
var list = document.querySelector('.list[data-field="' + field + '"]');
var n = list.querySelectorAll(".row").length;
list.appendChild(rowFor(field + "[" + n + "]"));
});
});
document.addEventListener("click", function(e) {
if (e.target && e.target.matches(".del")) {
e.target.closest(".row").remove();
}
});
function collect() {
var out = { title: "", acl: { allow: [], deny: [] }, admins: [], apps: {} };
out.title = document.getElementById("f-title").value;
document.querySelectorAll('.list[data-field="acl.allow"] input').forEach(function(i) { if (i.value.trim()) out.acl.allow.push(i.value.trim()); });
document.querySelectorAll('.list[data-field="acl.deny"] input').forEach(function(i) { if (i.value.trim()) out.acl.deny.push(i.value.trim()); });
document.querySelectorAll('.list[data-field="admins"] input').forEach(function(i) { if (i.value.trim()) out.admins.push(i.value.trim()); });
document.querySelectorAll('input[data-apps-key]').forEach(function(i) {
var k = i.dataset.appsKey;
var v = i.value.trim();
if (v) out.apps[k] = v;
});
return out;
}
function clearErrors() {
document.querySelectorAll(".row .err").forEach(function(e) { e.textContent = ""; });
document.getElementById("ok-banner").hidden = true;
}
function showErrors(errs) {
errs.forEach(function(e) {
// Apps fields look like "apps.<key>" — surface inline next to the row.
if (e.field.indexOf("apps.") === 0) {
var key = e.field.substring("apps.".length);
var input = document.querySelector('input[data-apps-key="' + CSS.escape(key) + '"]');
if (input) {
var span = input.parentElement.querySelector(".err");
if (span) span.textContent = e.message;
return;
}
}
var sel = '[data-field="' + CSS.escape(e.field) + '"]';
var input = document.querySelector(sel);
if (input) {
var span = input.parentElement.querySelector(".err");
if (span) span.textContent = e.message;
} else {
// Top-level field error (e.g. "admins" without index, or "title").
alert(e.field + ": " + e.message);
}
});
}
if (canEdit) {
document.getElementById("editor").addEventListener("submit", function(ev) {
ev.preventDefault();
clearErrors();
var body = JSON.stringify(collect());
fetch(apiURL, {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
credentials: "same-origin",
body: body
}).then(function(r) {
return r.text().then(function(t) { return { ok: r.ok, status: r.status, text: t }; });
}).then(function(res) {
if (res.ok) {
document.getElementById("ok-banner").hidden = false;
window.scrollTo(0, 0);
return;
}
try {
var parsed = JSON.parse(res.text);
if (parsed.errors) { showErrors(parsed.errors); return; }
} catch (e) {}
alert("HTTP " + res.status + ": " + res.text);
}).catch(function(err) {
alert(String(err));
});
});
if (!isRoot) {
document.getElementById("del").addEventListener("click", function() {
if (!confirm("Delete " + path + "/.zddc?\n\nInherited rules from parent .zddc files will still apply.")) return;
fetch(apiURL, { method: "DELETE", credentials: "same-origin" }).then(function(r) {
if (r.ok) {
window.location.href = "?path=" + encodeURIComponent(path);
} else {
r.text().then(function(t) { alert("HTTP " + r.status + ": " + t); });
}
});
});
}
}
})();
</script>
</body>
</html>
`))

View file

@ -21,9 +21,6 @@ const ZddcFileBasename = ".zddc"
// IsZddcFileRequest reports whether urlPath ends with the raw .zddc
// leaf. Used by the dispatcher to route a GET/HEAD to ServeZddcFile
// before the dot-prefix guard rejects it.
//
// Excludes the `.zddc.html` editor leaf, which is handled by
// IsZddcEditorRequest / ServeZddcEditorAtPath.
func IsZddcFileRequest(urlPath string) bool {
clean := strings.TrimSuffix(urlPath, "/")
return strings.HasSuffix(clean, "/"+ZddcFileBasename) ||
@ -41,15 +38,18 @@ func IsZddcFileRequest(urlPath string) bool {
// Virtual: if it does not exist, a synthetic body is returned with a
// cascade summary so the operator can see what rules are
// effective at this depth. The synthetic body is clearly
// marked with comments — saving it via the editor (`<dir>/.zddc.html`)
// materialises a real file. The virtual response sets
// X-ZDDC-Source: virtual so the client can distinguish.
// marked with comments — PUT-saving its bytes back to the
// same URL (through the file API) materialises a real file.
// The virtual response sets X-ZDDC-Source: virtual so the
// client can distinguish.
func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
w.Header().Set("Allow", "GET, HEAD, PUT, DELETE")
http.Error(w,
"Method Not Allowed — .zddc is read-only via this URL.\n"+
"To edit, open <dir>/.zddc.html (form-based editor, admin-only).\n",
"Method Not Allowed — this URL serves the .zddc bytes for "+
"GET/HEAD. Writes go through the file API at the same "+
"URL (PUT to overwrite, DELETE to remove); for an editor, "+
"open <dir>/?file=.zddc to land on the YAML/CodeMirror view.\n",
http.StatusMethodNotAllowed)
return
}
@ -133,9 +133,10 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
func renderVirtualZddc(fsRoot, dirAbs string, chain zddc.PolicyChain) string {
var b strings.Builder
fmt.Fprintf(&b, "# Virtual .zddc — no file on disk at this directory yet.\n")
fmt.Fprintf(&b, "# Rules below are inherited from ancestors. To override at\n")
fmt.Fprintf(&b, "# this level, edit via the form editor at <dir>/.zddc.html\n")
fmt.Fprintf(&b, "# (admin-only). Saving creates a real file here.\n")
fmt.Fprintf(&b, "# Rules below are inherited from ancestors. Edit + save\n")
fmt.Fprintf(&b, "# (PUT) through the YAML editor in browse (admin-only)\n")
fmt.Fprintf(&b, "# to override at this level — the save materialises a\n")
fmt.Fprintf(&b, "# real file here.\n")
fmt.Fprintf(&b, "#\n")
fmt.Fprintf(&b, "# Effective cascade at %s:\n", urlPathOf(fsRoot, dirAbs))

View file

@ -1,350 +0,0 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// ZddcProfilePathPrefix is the URL prefix for the .zddc editor (both API and
// HTML page). All routes under this prefix require either super-admin
// authority (IsAdmin) or some subtree-admin grant; non-admins-of-anything
// receive 404 to keep editor existence hidden, matching the /.profile gate.
const ZddcProfilePathPrefix = ProfilePathPrefix + "/zddc"
// ServeZddc dispatches all /.profile/zddc/* routes. ServeProfile already
// trimmed the /.profile prefix; this handler is reachable for any admin
// (super or subtree), so it re-checks authorization itself rather than
// inheriting one from the caller.
//
// Sub-routes:
//
// GET /.profile/zddc?path=<dir> → JSON: parsed file + chain
// POST /.profile/zddc?path=<dir> → write (JSON body)
// DELETE /.profile/zddc?path=<dir> → remove file
// GET /.profile/zddc/tree → JSON: list of editable dirs
// GET /.profile/zddc/edit?path=<dir> → server-rendered editor page
func ServeZddc(cfg config.Config, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
// Hide existence from anyone who has no admin authority anywhere.
if !hasAnyAdminScope(cfg.Root, email) {
http.NotFound(w, r)
return
}
// r.URL.Path is the full URL path; sub-route is everything after
// /.profile/zddc.
sub := strings.TrimPrefix(r.URL.Path, ZddcProfilePathPrefix)
switch {
case sub == "" || sub == "/":
serveZddcAPI(cfg, w, r)
case sub == "/tree":
serveZddcTree(cfg, w, r)
case sub == "/edit":
serveZddcEditor(cfg, w, r)
case strings.HasPrefix(sub, "/assets/"):
serveZddcAssets(cfg, w, r)
default:
http.NotFound(w, r)
}
}
// hasAnyAdminScope reports whether email has admin authority anywhere in
// the tree (root super-admin OR a subtree-admin grant on any .zddc).
// This is the gate for surfacing the editor at all.
func hasAnyAdminScope(fsRoot, email string) bool {
if email == "" {
return false
}
if zddc.IsAdmin(fsRoot, email) {
return true
}
dirs, _ := zddc.ScanZddcFiles(fsRoot)
for _, d := range dirs {
if zddc.IsSubtreeAdmin(fsRoot, d, email) {
return true
}
}
return false
}
// resolvePath translates a URL `path=` query (relative to fsRoot, with
// '/' separator and leading '/') into an absolute filesystem path. It
// rejects path traversal and any segment beginning with '.' or '_' so
// reserved namespaces (e.g. .devshell) are not editable through this
// API. Returns the cleaned absolute path or an error suitable for a 404.
func resolvePath(fsRoot, urlPath string) (string, error) {
urlPath = strings.TrimSpace(urlPath)
if urlPath == "" {
urlPath = "/"
}
if !strings.HasPrefix(urlPath, "/") {
return "", errors.New("path must be absolute (start with /)")
}
cleanURL := filepath.ToSlash(filepath.Clean(urlPath))
// Reject reserved-prefix segments so the editor cannot create
// .foo/.zddc or _bar/.zddc through the API.
for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") {
if seg == "" {
continue
}
if strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") {
return "", errors.New("reserved-prefix path segment")
}
}
rel := strings.TrimPrefix(cleanURL, "/")
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
abs = filepath.Clean(abs)
// Path containment.
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
return "", errors.New("path escapes root")
}
return abs, nil
}
// urlPathOf produces the URL form of an absolute filesystem path under
// fsRoot. The result is "/" for fsRoot itself, otherwise "/<rel>".
func urlPathOf(fsRoot, abs string) string {
if abs == fsRoot {
return "/"
}
rel, err := filepath.Rel(fsRoot, abs)
if err != nil {
return "/"
}
return "/" + filepath.ToSlash(rel)
}
// chainEntry is one level of the effective-chain in API responses.
type chainEntry struct {
Dir string `json:"dir"`
Exists bool `json:"exists"`
Title string `json:"title,omitempty"`
ACL zddc.ACLRules `json:"acl"`
Admins []string `json:"admins,omitempty"`
}
type zddcGetResponse struct {
Path string `json:"path"`
Exists bool `json:"exists"`
IsRoot bool `json:"is_root"`
CanEdit bool `json:"can_edit"`
File zddc.ZddcFile `json:"file"`
EffectiveChain []chainEntry `json:"effective_chain"`
}
type zddcWriteRequest struct {
Title string `json:"title"`
ACL zddc.ACLRules `json:"acl"`
Admins []string `json:"admins"`
Apps map[string]string `json:"apps,omitempty"`
}
type writeError struct {
Errors []zddc.FieldError `json:"errors"`
}
// serveZddcAPI handles /.profile/zddc?path=<dir> for GET, POST, DELETE.
func serveZddcAPI(cfg config.Config, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path"))
if err != nil {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
serveZddcGet(cfg, abs, email, w, r)
case http.MethodPost, http.MethodPut:
serveZddcWrite(cfg, abs, email, w, r)
case http.MethodDelete:
serveZddcDelete(cfg, abs, email, w, r)
default:
w.Header().Set("Allow", "GET, POST, DELETE")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
}
func serveZddcGet(cfg config.Config, abs, email string, w http.ResponseWriter, r *http.Request) {
zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc"))
if err != nil {
http.Error(w, "Bad Request: cannot parse existing .zddc: "+err.Error(), http.StatusBadRequest)
return
}
exists := false
if _, statErr := os.Stat(filepath.Join(abs, ".zddc")); statErr == nil {
exists = true
}
chain, _ := zddc.EffectivePolicy(cfg.Root, abs)
dirs := chainDirs(cfg.Root, abs)
entries := make([]chainEntry, 0, len(chain.Levels))
for i, level := range chain.Levels {
levelDir := dirs[i]
levelExists := false
if _, err := os.Stat(filepath.Join(levelDir, ".zddc")); err == nil {
levelExists = true
}
entries = append(entries, chainEntry{
Dir: urlPathOf(cfg.Root, levelDir),
Exists: levelExists,
Title: level.Title,
ACL: level.ACL,
Admins: level.Admins,
})
}
resp := zddcGetResponse{
Path: urlPathOf(cfg.Root, abs),
Exists: exists,
IsRoot: abs == cfg.Root,
CanEdit: zddc.CanEditZddc(cfg.Root, abs, email),
File: zf,
EffectiveChain: entries,
}
writeJSON(w, resp)
}
// chainDirs reproduces EffectivePolicy's directory walk so the chainEntry
// list can carry the directory of each level. Kept local to this file to
// avoid widening the zddc package's API.
func chainDirs(fsRoot, dirPath string) []string {
fsRoot = filepath.Clean(fsRoot)
dirPath = filepath.Clean(dirPath)
dirs := []string{fsRoot}
if dirPath == fsRoot {
return dirs
}
rel, err := filepath.Rel(fsRoot, dirPath)
if err != nil || rel == "." {
return dirs
}
current := fsRoot
for _, part := range strings.Split(rel, string(filepath.Separator)) {
current = filepath.Join(current, part)
dirs = append(dirs, current)
}
return dirs
}
func serveZddcWrite(cfg config.Config, abs, email string, w http.ResponseWriter, r *http.Request) {
if !zddc.CanEditZddc(cfg.Root, abs, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := os.MkdirAll(abs, 0o755); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var req zddcWriteRequest
if err := dec.Decode(&req); err != nil {
http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest)
return
}
zf := zddc.ZddcFile{
Title: req.Title,
ACL: req.ACL,
Admins: req.Admins,
Apps: req.Apps,
}
if errs := zddc.ValidateFile(zf); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(writeError{Errors: errs})
return
}
// Root-only invariant: writer must remain in the post-write Admins
// list. Recovery requires filesystem access we don't have.
if abs == cfg.Root {
stillAdmin := false
for _, p := range zf.Admins {
if zddc.MatchesPattern(p, email) {
stillAdmin = true
break
}
}
if !stillAdmin {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(writeError{Errors: []zddc.FieldError{{
Field: "admins",
Message: "you cannot remove yourself from the root admins list",
}}})
return
}
}
if err := zddc.WriteFile(abs, zf); err != nil {
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"ok": true, "path": urlPathOf(cfg.Root, abs)})
}
func serveZddcDelete(cfg config.Config, abs, email string, w http.ResponseWriter, r *http.Request) {
if !zddc.CanEditZddc(cfg.Root, abs, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Root-only invariant: cannot delete root .zddc, that would leave no
// way to administer the server (and CanEditZddc(root) would return
// false on any subsequent request).
if abs == cfg.Root {
http.Error(w, "Cannot delete root .zddc — edit instead", http.StatusBadRequest)
return
}
if err := zddc.DeleteFile(abs); err != nil {
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
type treeEntry struct {
Path string `json:"path"`
CanEdit bool `json:"can_edit"`
Title string `json:"title,omitempty"`
}
// serveZddcTree returns the list of every directory containing a .zddc
// file, with a per-entry can_edit flag. The list is filtered to entries
// the caller has at least admin visibility for (read or edit).
func serveZddcTree(cfg config.Config, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
dirs, _ := zddc.ScanZddcFiles(cfg.Root)
out := make([]treeEntry, 0, len(dirs))
for _, d := range dirs {
if !zddc.IsSubtreeAdmin(cfg.Root, d, email) && !zddc.IsAdmin(cfg.Root, email) {
continue
}
var title string
if zf, err := zddc.ParseFile(filepath.Join(d, ".zddc")); err == nil {
title = zf.Title
}
out = append(out, treeEntry{
Path: urlPathOf(cfg.Root, d),
CanEdit: zddc.CanEditZddc(cfg.Root, d, email),
Title: title,
})
}
writeJSON(w, out)
}

View file

@ -1,437 +0,0 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// zddcTestSetup writes a tree of .zddc files and returns the root and a
// helper that builds requests with an injected user email. files keys
// are paths relative to root; the empty string is the root itself. Each
// path is created as a directory; if the value is non-empty it is
// written as that directory's .zddc.
func zddcTestSetup(t *testing.T, files map[string]string) (cfg config.Config, do func(method, target, email, body string) *httptest.ResponseRecorder) {
t.Helper()
root := t.TempDir()
for rel, body := range files {
dir := filepath.Join(root, rel)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
zddc.InvalidateCache(dir)
if body == "" {
continue
}
if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
}
cfg = config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
do = func(method, target, email, body string) *httptest.ResponseRecorder {
var rdr *bytes.Reader
if body != "" {
rdr = bytes.NewReader([]byte(body))
}
var req *http.Request
if rdr != nil {
req = httptest.NewRequest(method, target, rdr)
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeZddc(cfg, rec, req)
return rec
}
return cfg, do
}
func TestServeZddcAuthGate(t *testing.T) {
// root admin = root@example.com; subtree admin alice@example.com on /projects.
cfg, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/x": "",
})
cases := []struct {
name string
method string
target string
email string
wantStatus int
}{
{"anon GET root", http.MethodGet, "/.profile/zddc?path=/", "", http.StatusNotFound},
{"non-admin GET root", http.MethodGet, "/.profile/zddc?path=/", "mallory@example.com", http.StatusNotFound},
{"super-admin GET root", http.MethodGet, "/.profile/zddc?path=/", "root@example.com", http.StatusOK},
{"subtree-admin GET root (read-only)", http.MethodGet, "/.profile/zddc?path=/", "alice@example.com", http.StatusOK},
{"subtree-admin GET own grant file (read-only)", http.MethodGet, "/.profile/zddc?path=/projects", "alice@example.com", http.StatusOK},
{"subtree-admin GET deeper", http.MethodGet, "/.profile/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
{"subtree-admin POST own grant file (forbidden)", http.MethodPost, "/.profile/zddc?path=/projects", "alice@example.com", http.StatusForbidden},
{"subtree-admin POST deeper (allowed)", http.MethodPost, "/.profile/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
{"super-admin POST root", http.MethodPost, "/.profile/zddc?path=/", "root@example.com", http.StatusOK},
{"non-admin POST anywhere", http.MethodPost, "/.profile/zddc?path=/projects/x", "mallory@example.com", http.StatusNotFound},
{"DELETE root rejected", http.MethodDelete, "/.profile/zddc?path=/", "root@example.com", http.StatusBadRequest},
{"super-admin DELETE leaf", http.MethodDelete, "/.profile/zddc?path=/projects/x", "root@example.com", http.StatusNoContent},
}
_ = cfg
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := ""
if tc.method == http.MethodPost {
if tc.target == "/.profile/zddc?path=/" {
// Root POST: writer must remain in admins list.
body = `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com"]}`
} else {
body = `{"title":"x","acl":{"allow":["*@example.com"],"deny":[]},"admins":[]}`
}
}
rec := do(tc.method, tc.target, tc.email, body)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d; body=%s", rec.Code, tc.wantStatus, rec.Body.String())
}
})
}
}
func TestServeZddcGetReturnsChain(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\nacl:\n allow: [\"*@example.com\"]\n",
"projects": "title: All Projects\n",
"projects/sub": "title: Substation\n",
})
rec := do(http.MethodGet, "/.profile/zddc?path=/projects/sub", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var resp zddcGetResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Path != "/projects/sub" {
t.Errorf("path = %q, want /projects/sub", resp.Path)
}
if !resp.CanEdit {
t.Errorf("CanEdit = false; root admin should edit anywhere")
}
if !resp.Exists {
t.Errorf("Exists = false but file was written")
}
if len(resp.EffectiveChain) != 3 {
t.Fatalf("chain length = %d, want 3 (root, projects, projects/sub)", len(resp.EffectiveChain))
}
if resp.EffectiveChain[0].Dir != "/" {
t.Errorf("chain[0].Dir = %q, want /", resp.EffectiveChain[0].Dir)
}
if resp.EffectiveChain[1].Dir != "/projects" {
t.Errorf("chain[1].Dir = %q, want /projects", resp.EffectiveChain[1].Dir)
}
if resp.EffectiveChain[2].Title != "Substation" {
t.Errorf("chain[2].Title = %q, want Substation", resp.EffectiveChain[2].Title)
}
}
func TestServeZddcPostValidatesGlob(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "",
})
body := `{"title":"x","acl":{"allow":["alice@@bad","good@example.com"],"deny":[]},"admins":[]}`
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
var we writeError
if err := json.Unmarshal(rec.Body.Bytes(), &we); err != nil {
t.Fatalf("decode err body: %v", err)
}
if len(we.Errors) == 0 || we.Errors[0].Field != "acl.allow[0]" {
t.Errorf("expected acl.allow[0] error, got %+v", we.Errors)
}
}
func TestServeZddcRootSelfDemotionRejected(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n - bob@example.com\n",
})
// root tries to remove themselves, leaving only bob.
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["bob@example.com"]}`
rec := do(http.MethodPost, "/.profile/zddc?path=/", "root@example.com", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (self-demotion rejected); body=%s", rec.Code, rec.Body.String())
}
}
func TestServeZddcRootKeepingSelfAccepted(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
})
// root adds bob alongside themselves — fine.
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com","bob@example.com"]}`
rec := do(http.MethodPost, "/.profile/zddc?path=/", "root@example.com", body)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestServeZddcWriteRoundTrip(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "",
})
body := `{"title":"Engineering","acl":{"allow":["*@varasys.io"],"deny":[]},"admins":["alice@varasys.io"]}`
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
if rec.Code != http.StatusOK {
t.Fatalf("write status = %d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodGet, "/.profile/zddc?path=/projects", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("get status = %d body=%s", rec.Code, rec.Body.String())
}
var resp zddcGetResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.File.Title != "Engineering" {
t.Errorf("title round-trip = %q, want Engineering", resp.File.Title)
}
if len(resp.File.Admins) != 1 || resp.File.Admins[0] != "alice@varasys.io" {
t.Errorf("admins round-trip = %v, want [alice@varasys.io]", resp.File.Admins)
}
}
func TestServeZddcWriteAppsRoundTrip(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "",
})
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":[],"apps":{` +
`"default":"https://zddc.varasys.io/releases:stable",` +
`"classifier":":beta",` +
`"archive":"https://my.local.stuff/releases"}}`
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
if rec.Code != http.StatusOK {
t.Fatalf("write status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodGet, "/.profile/zddc?path=/projects", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("get status=%d body=%s", rec.Code, rec.Body.String())
}
var resp zddcGetResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if got := resp.File.Apps["default"]; got != "https://zddc.varasys.io/releases:stable" {
t.Errorf("default round-trip = %q", got)
}
if got := resp.File.Apps["classifier"]; got != ":beta" {
t.Errorf("classifier round-trip = %q", got)
}
if got := resp.File.Apps["archive"]; got != "https://my.local.stuff/releases" {
t.Errorf("archive round-trip = %q", got)
}
}
func TestServeZddcWriteAppsRejectsBadSpec(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "",
})
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":[],"apps":{"archive":"this is garbage"}}`
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d (want 400)", rec.Code)
}
if !strings.Contains(rec.Body.String(), `"apps.archive"`) {
t.Errorf("expected per-field error for apps.archive; got %s", rec.Body.String())
}
}
func TestServeZddcEditorRendersAppsSection(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "apps:\n default: \":beta\"\n classifier: \"v0.0.4\"\n",
})
rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
"Apps (tool HTML sources)",
`data-apps-key="default"`,
`data-apps-key="archive"`,
`data-apps-key="classifier"`,
`data-apps-key="browse"`,
`data-apps-key="transmittal"`,
`data-apps-key="landing"`,
`value=":beta"`,
`value="v0.0.4"`,
"classifier_v0.0.4.html", // preview reflects the cascaded resolution
} {
if !strings.Contains(body, want) {
t.Errorf("editor body missing %q", want)
}
}
}
func TestServeZddcTreeFiltersByVisibility(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"alpha": "admins:\n - alice@example.com\n",
"alpha/x": "title: alpha-x\n",
"beta": "admins:\n - bob@example.com\n",
})
// alice sees alpha (her grant) and alpha/x (descendant), but not beta.
rec := do(http.MethodGet, "/.profile/zddc/tree", "alice@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var entries []treeEntry
if err := json.Unmarshal(rec.Body.Bytes(), &entries); err != nil {
t.Fatalf("decode: %v", err)
}
seen := map[string]bool{}
for _, e := range entries {
seen[e.Path] = true
}
if !seen["/alpha"] || !seen["/alpha/x"] {
t.Errorf("alice should see /alpha and /alpha/x; got %v", seen)
}
if seen["/beta"] {
t.Errorf("alice should NOT see /beta; got %v", seen)
}
}
func TestServeZddcEditorRenders(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "title: Engineering\n",
})
rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Engineering") {
t.Errorf("editor should pre-fill title; body did not contain 'Engineering'")
}
if !strings.Contains(body, "/.profile/zddc?path=") {
t.Errorf("editor should reference API URL; body lacks /.profile/zddc?path=")
}
if !strings.Contains(body, "Subtree admins of /projects") {
t.Errorf("editor should label admins section as subtree (not bootstrap) for non-root file")
}
}
func TestServeZddcEditorReadOnlyForNonEditor(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
})
// alice viewing her own grant file: read-only.
rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "alice@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Read-only") {
t.Errorf("editor should show Read-only banner for non-editor; body lacks it")
}
}
func TestServeZddcRejectsReservedPathSegments(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
})
for _, p := range []string{"/.foo", "/_bar", "/projects/.evil"} {
rec := do(http.MethodGet, "/.profile/zddc?path="+p, "root@example.com", "")
if rec.Code != http.StatusNotFound {
t.Errorf("path=%q expected 404, got %d", p, rec.Code)
}
}
}
func TestServeZddcAdminDispatchUnchangedForOtherRoutes(t *testing.T) {
// Confirm that putting /.profile/zddc/* under the broader gate did not
// regress the super-admin gate on /.profile/whoami etc.
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeProfile(cfg, nil, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("non-admin /.profile/whoami got %d, want 404", rec.Code)
}
req = httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec = httptest.NewRecorder()
ServeProfile(cfg, nil, nil, rec, req)
if rec.Code != http.StatusOK {
t.Errorf("super-admin /.profile/whoami got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestServeZddcAssetsCustomCSS(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
if err := os.WriteFile(filepath.Join(root, ".admin.css"), []byte("body { color: red; }"), 0o644); err != nil {
t.Fatalf("write .admin.css: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.profile/zddc/assets/custom.css", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec := httptest.NewRecorder()
ServeZddc(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
t.Errorf("Content-Type = %q, want text/css...", ct)
}
if !strings.Contains(rec.Body.String(), "color: red") {
t.Errorf("body does not contain custom CSS")
}
}
func TestServeZddcAssetsAbsentReturns404(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.profile/zddc/assets/custom.css", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec := httptest.NewRecorder()
ServeZddc(cfg, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status=%d, want 404", rec.Code)
}
}

View file

@ -37,4 +37,13 @@ type FileInfo struct {
// names. Real on-disk dirs and virtual placeholders both get
// Declared=true when their name matches the cascade.
Declared bool `json:"declared,omitempty"`
// Title is the human-readable project / folder name pulled from
// the entry's own `.zddc` file (its `title:` field), surfaced
// directly in the directory listing so a generic client can
// render a friendly label without an extra round-trip per entry.
// Most useful at the deployment root, where it gives the landing
// page project names without a separate API. Empty when the
// entry has no `.zddc` or its `.zddc` doesn't set `title:`.
Title string `json:"title,omitempty"`
}

View file

@ -2,7 +2,76 @@ package zddc
import "path/filepath"
// IsAdmin reports whether email is listed in the admins entry of the ROOT
// Principal is the caller identity passed to admin-authority checks.
//
// Email is the authenticated user's email (or token-issued email for
// machine clients). Elevated reports whether the caller has opted into
// their admin powers for THIS request. The opt-in is sourced upstream
// (in handler.ACLMiddleware): machine clients with bearer tokens are
// implicitly elevated; browser clients elevate via the zddc-elevate
// cookie set by the UI's elevation toggle. The Principal is built once
// per request — every admin function takes it, so the type system
// enforces the gate at every call site.
//
// Sudo-style semantics: an admin who hasn't elevated is treated as a
// regular user. Their normal ACL grants still apply (those use the
// email directly, not this Principal); only admin escape hatches
// (WORM bypass, auto-own takeover, .zddc edit authority, profile
// admin scaffolds) are gated.
type Principal struct {
Email string
Elevated bool
}
// gate is the common short-circuit for every admin-authority check:
// an empty email never matches, and an admin who hasn't elevated is
// treated as a non-admin regardless of what the .zddc files name.
func (p Principal) gate() bool {
return p.Email != "" && p.Elevated
}
// HasAnyAdminGrant reports whether email is named as an admin somewhere
// in the cascade — either the root's admins: list (super-admin) or any
// subtree-admin grant via paths:.<dir>.admins. ELEVATION-INDEPENDENT:
// answers "could this user opt into admin powers if they wanted to?",
// which the header elevation toggle reads to decide whether to render
// itself. The elevation-AWARE checks (IsAdmin, IsSubtreeAdmin,
// CanEditZddc) take a Principal and short-circuit on !Elevated;
// this function just asks the cascade.
//
// Returns false for an empty email so anonymous callers can't probe.
func HasAnyAdminGrant(fsRoot, email string) bool {
if email == "" {
return false
}
// Root super-admin.
if zf, err := ParseFile(filepath.Join(fsRoot, ".zddc")); err == nil {
for _, pattern := range zf.Admins {
if MatchesPattern(pattern, email) {
return true
}
}
}
// Subtree-admin anywhere — walk every .zddc and check its
// effective chain.
dirs, _ := ScanZddcFiles(fsRoot)
for _, d := range dirs {
chain, err := EffectivePolicy(fsRoot, d)
if err != nil {
continue
}
for i, level := range chain.Levels {
for _, principal := range level.Admins {
if MatchesPrincipal(principal, email, chain, i, ModeDelegated) {
return true
}
}
}
}
return false
}
// IsAdmin reports whether p is listed in the admins entry of the ROOT
// .zddc file (<fsRoot>/.zddc). Subdirectory .zddc files' admins keys are
// deliberately ignored by this function — it gates the server-wide debug
// admin role (/.profile/{whoami,config,logs}) which only the bootstrap
@ -13,9 +82,9 @@ import "path/filepath"
//
// Patterns use the same glob syntax as acl.allow / acl.deny (see
// MatchesPattern). Returns false if the root file does not exist, has an
// empty Admins list, or no entry matches. An empty email never matches.
func IsAdmin(fsRoot, email string) bool {
if email == "" {
// empty Admins list, no entry matches, or the principal hasn't elevated.
func IsAdmin(fsRoot string, p Principal) bool {
if !p.gate() {
return false
}
zf, err := ParseFile(filepath.Join(fsRoot, ".zddc"))
@ -23,7 +92,7 @@ func IsAdmin(fsRoot, email string) bool {
return false
}
for _, pattern := range zf.Admins {
if MatchesPattern(pattern, email) {
if MatchesPattern(pattern, p.Email) {
return true
}
}
@ -43,8 +112,8 @@ func IsAdmin(fsRoot, email string) bool {
// subtree?". For write authority over a specific .zddc file, use
// CanEditZddc, which adds the strict-ancestor rule that prevents
// self-elevation.
func IsSubtreeAdmin(fsRoot, dirPath, email string) bool {
if email == "" {
func IsSubtreeAdmin(fsRoot, dirPath string, p Principal) bool {
if !p.gate() {
return false
}
chain, err := EffectivePolicy(fsRoot, dirPath)
@ -53,7 +122,7 @@ func IsSubtreeAdmin(fsRoot, dirPath, email string) bool {
}
for i, level := range chain.Levels {
for _, principal := range level.Admins {
if MatchesPrincipal(principal, email, chain, i, ModeDelegated) {
if MatchesPrincipal(principal, p.Email, chain, i, ModeDelegated) {
return true
}
}
@ -74,8 +143,8 @@ func IsSubtreeAdmin(fsRoot, dirPath, email string) bool {
// strict ancestor, so it is governed by its own Admins list (the same
// allowlist IsAdmin checks). The very first super-admin is created by
// hand-editing this file at server install time.
func CanEditZddc(fsRoot, dirPath, email string) bool {
if email == "" {
func CanEditZddc(fsRoot, dirPath string, p Principal) bool {
if !p.gate() {
return false
}
fsRoot = filepath.Clean(fsRoot)
@ -89,7 +158,7 @@ func CanEditZddc(fsRoot, dirPath, email string) bool {
// Bootstrap: the root file is governed by its own Admins.
if dirPath == fsRoot {
for _, pattern := range chain.Levels[0].Admins {
if MatchesPattern(pattern, email) {
if MatchesPattern(pattern, p.Email) {
return true
}
}
@ -103,7 +172,7 @@ func CanEditZddc(fsRoot, dirPath, email string) bool {
// deepest level, which is dirPath, never confers self-edit rights).
for i := 0; i < len(chain.Levels)-1; i++ {
for _, principal := range chain.Levels[i].Admins {
if MatchesPrincipal(principal, email, chain, i, ModeDelegated) {
if MatchesPrincipal(principal, p.Email, chain, i, ModeDelegated) {
return true
}
}

View file

@ -77,7 +77,7 @@ func TestIsAdmin(t *testing.T) {
t.Fatalf("write .zddc: %v", err)
}
}
if got := IsAdmin(root, tc.email); got != tc.want {
if got := IsAdmin(root, Principal{Email: tc.email, Elevated: true}); got != tc.want {
t.Errorf("IsAdmin(%q, %q) = %v, want %v", root, tc.email, got, tc.want)
}
})
@ -103,11 +103,28 @@ func TestIsAdminSubdirIgnored(t *testing.T) {
t.Fatalf("write subdir .zddc: %v", err)
}
if IsAdmin(root, "mallory@example.com") {
if IsAdmin(root, Principal{Email: "mallory@example.com", Elevated: true}) {
t.Error("subdir .zddc admins entry was honored — that is a privilege-escalation hole")
}
}
// TestIsAdminUnelevated asserts the elevation gate: an admin email
// who hasn't opted into their powers (Elevated:false) is treated as a
// non-admin, even when the root .zddc grants them.
func TestIsAdminUnelevated(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("admins:\n - alice@example.com\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
if IsAdmin(root, Principal{Email: "alice@example.com", Elevated: false}) {
t.Error("un-elevated admin reported as IsAdmin — elevation gate broken")
}
if !IsAdmin(root, Principal{Email: "alice@example.com", Elevated: true}) {
t.Error("elevated admin not recognised — gate is too strict")
}
}
// fixture writes a tree of .zddc files. Keys are paths relative to root;
// the empty string means root itself ("<root>/.zddc"). Values are file
// contents. Intermediate directories are created as needed. Each path is
@ -229,7 +246,7 @@ func TestIsSubtreeAdmin(t *testing.T) {
t.Fatalf("mkdir target: %v", err)
}
InvalidateCache(dir)
if got := IsSubtreeAdmin(root, dir, tc.email); got != tc.want {
if got := IsSubtreeAdmin(root, dir, Principal{Email: tc.email, Elevated: true}); got != tc.want {
t.Errorf("IsSubtreeAdmin(%q, %q) = %v, want %v",
tc.dir, tc.email, got, tc.want)
}
@ -395,7 +412,7 @@ func TestCanEditZddc(t *testing.T) {
t.Fatalf("mkdir target: %v", err)
}
InvalidateCache(dir)
if got := CanEditZddc(root, dir, tc.email); got != tc.want {
if got := CanEditZddc(root, dir, Principal{Email: tc.email, Elevated: true}); got != tc.want {
t.Errorf("CanEditZddc(dir=%q, email=%q) = %v, want %v",
tc.dir, tc.email, got, tc.want)
}

View file

@ -58,18 +58,18 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
// Subtree-admin at working/ and staging/ (via admins: [document_controller]
// in the embedded cascade — role-aware now).
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "working"), dc) {
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "working"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should be subtree-admin of working/")
}
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "staging"), dc) {
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "staging"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should be subtree-admin of staging/")
}
// NOT subtree-admin of archive/ (so WORM still binds them there).
if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), dc) {
if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should NOT be subtree-admin of archive/")
}
// Subtree-admin reaches inside a fenced per-user working home.
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "working", "alice@example.com"), dc) {
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "working", "alice@example.com"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller (subtree-admin of working/) should reach inside a fenced user home")
}
}