diff --git a/AGENTS.md b/AGENTS.md index ffb6b3b..c44ac92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -569,6 +569,18 @@ The tokens directory inherits the existing `.zddc.d/` exclusion: dot-prefix segm Implementation: `zddc/internal/auth/` (storage), `zddc/internal/handler/tokenhandler.go` (HTTP layer), middleware extension in `zddc/internal/handler/middleware.go`. +### Admin elevation (sudo-style) + +Admins are treated as normal users by default; admin escape hatches (WORM bypass, auto-own takeover, `.zddc` edit authority, profile admin scaffolds) require an explicit per-request opt-in. The toggle lives in every tool's header (left of the theme button) and writes a `zddc-elevate=1` cookie (Max-Age=1800, SameSite=Lax) — 30-minute sudo window before it auto-expires. + +Server-side the model is `zddc.Principal{Email, Elevated}`. `ACLMiddleware` builds it once per request and stashes it in context; `IsAdmin` / `IsSubtreeAdmin` / `CanEditZddc` take a `Principal` parameter rather than a bare email. That signature change is the enforcement mechanism — the compiler tells you when an admin call site doesn't thread elevation, so a "forgot to gate this" mistake doesn't compile. `PrincipalFromContext(r)` is the one-call-per-site bundling helper. + +Bearer tokens are **implicitly elevated** — CLI clients and the mirror process can't toggle a cookie, and their authority is the bearer's full grant by design. Browser sessions elevate only when the user opts in. + +`/.profile/access` exposes `can_elevate` (elevation-independent "does this email have any admin grant anywhere?") so the header toggle can render itself for an un-elevated admin who hasn't opted in yet. The access log captures `elevated=` per request for forensics. + +Implementation: `zddc/internal/zddc/admin.go` (Principal struct + gated functions), `zddc/internal/handler/middleware.go` (cookie/bearer → ElevatedKey context value), `shared/elevation.{js,css}` (header toggle UI, concat'd into every tool's bundle). + ### Release tagging zddc-server has no separate release script. The top-level `./build alpha|beta|release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the lockstep symlink chain (one set of symlinks per platform), regenerates the per-version + per-channel stub pages, refreshes the index, and (on stable) tags `zddc-server-v` alongside the eight HTML-tool tags. diff --git a/CLAUDE.md b/CLAUDE.md index 9771f51..375d7ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,5 +82,6 @@ No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSI - **``** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining. - **All ZDDC parsing/formatting/hashing goes through `window.zddc`** (from `shared/zddc.js` + `shared/hash.js` + `shared/zddc-filter.js`). API: `parseFilename`, `parseFolder`, `parseRevision`, `formatFilename`, `formatFolder`, `compareRevisions`, `isValidStatus`, `splitExtension`, `joinExtension`, `crypto.{sha256Hex, sha256String, sha256File, bytesToHex}`, `filter.{parse, matches}`. File objects across tools use `trackingNumber` (string) and `extension` (string, **no leading dot** — use `zddc.joinExtension(name, ext)` to build a filename). Add edge cases to `tests/zddc.spec.js`, not per-tool tests. - **Two globals only**: `window.app` (per-tool app state + modules) and `window.zddc` (shared library). No others — anything that crosses tool boundaries goes through one of these. +- **Admin elevation is sudo-style.** Admins behave as normal users by default; opting into admin powers is per-request and gated by the `zddc-elevate=1` cookie (Max-Age=1800, set by the header toggle in every tool). Server-side: `zddc.Principal{Email, Elevated}` is built once per request by `handler.ACLMiddleware` and threaded into `IsAdmin`/`IsSubtreeAdmin`/`CanEditZddc` — the compiler enforces the gate at every admin call site (no easy "forgot to check elevation" mistake). Bearer-token requests are implicitly elevated since CLI clients can't toggle a cookie; browser sessions elevate only when the user clicks the header checkbox. `/.profile/access` exposes `can_elevate` (elevation-independent "does this email have admin authority anywhere?") so the header toggle can decide whether to render itself for an un-elevated admin. The access-log captures the `elevated` flag per request for forensics. - **Worktrees live at `~/src/zddc-`.** Check `git worktree list` before starting a feature branch; never `git checkout`/`switch` inside a worktree another agent might be using. - **Build scripts are POSIX `sh` with `set -eu`**, not bash. `concat_files` takes positional args only. diff --git a/archive/build.sh b/archive/build.sh index 56aba3c..47d2733 100644 --- a/archive/build.sh +++ b/archive/build.sh @@ -22,6 +22,7 @@ concat_files \ "../shared/fonts.css" \ "../shared/base.css" \ "../shared/toast.css" \ + "../shared/elevation.css" \ "../shared/nav.css" \ "../shared/logo.css" \ "css/base.css" \ @@ -64,6 +65,7 @@ concat_files \ "js/events.js" \ "js/app.js" \ "../shared/help.js" \ + "../shared/elevation.js" \ > "$js_raw" # Escape 'ZDDC Archive {{BUILD_LABEL}} - +
- + + +
@@ -240,7 +246,7 @@

Welcome to ZDDC Archive

-

Click Add Local Directory to select an archive folder to browse.

+

Click Use Local Directory to select an archive folder to browse.

This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.

How to navigate:

    @@ -285,7 +291,7 @@

    Getting Started

    1. When opened from a web server, the archive loads automatically from that server.
    2. -
    3. Click Add Local Directory to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.
    4. +
    5. Click Use Local Directory to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.
    6. The browser scans for grouping folders and transmittal folders automatically.
    7. Select folders in the left panel to see their files in the main table.
    diff --git a/classifier/build.sh b/classifier/build.sh index a9f6dc3..16eb7b1 100644 --- a/classifier/build.sh +++ b/classifier/build.sh @@ -22,6 +22,7 @@ concat_files \ "../shared/fonts.css" \ "../shared/base.css" \ "../shared/toast.css" \ + "../shared/elevation.css" \ "../shared/nav.css" \ "../shared/logo.css" \ "css/base.css" \ @@ -62,6 +63,7 @@ concat_files \ "js/sort.js" \ "js/excel.js" \ "../shared/help.js" \ + "../shared/elevation.js" \ > "$js_raw" # Escape 'ZDDC Classifier {{BUILD_LABEL}}
- +
- + + +
@@ -154,7 +160,7 @@
  • Rename one file or all modified files at once
  • -

    Click Add Local Directory to begin.

    +

    Click Use Local Directory to begin.

    This application works entirely in your browser. No data is transmitted to any server.

    @@ -173,7 +179,7 @@

    Getting Started

      -
    1. Click Add Local Directory to open a folder containing files to rename.
    2. +
    3. Click Use Local Directory to open a folder containing files to rename.
    4. The folder tree on the left shows all sub-folders. Click a folder to load its files.
    5. Edit cells in the spreadsheet to set the new filename components.
    6. Click Save All (or save individual rows) to rename the files on disk.
    7. diff --git a/form/build.sh b/form/build.sh index 1e9aba3..16fcdee 100755 --- a/form/build.sh +++ b/form/build.sh @@ -21,6 +21,7 @@ concat_files \ "../shared/fonts.css" \ "../shared/base.css" \ "../shared/toast.css" \ + "../shared/elevation.css" \ "../shared/nav.css" \ "../shared/logo.css" \ "css/form.css" \ @@ -32,6 +33,7 @@ concat_files \ "../shared/nav.js" \ "../shared/logo.js" \ "../shared/help.js" \ + "../shared/elevation.js" \ "js/app.js" \ "js/context.js" \ "js/util.js" \ diff --git a/form/template.html b/form/template.html index e5b167f..5dda43b 100644 --- a/form/template.html +++ b/form/template.html @@ -26,6 +26,12 @@
      + +
      diff --git a/landing/build.sh b/landing/build.sh index f93ba71..ecb984f 100755 --- a/landing/build.sh +++ b/landing/build.sh @@ -21,6 +21,7 @@ concat_files \ "../shared/fonts.css" \ "../shared/base.css" \ "../shared/toast.css" \ + "../shared/elevation.css" \ "../shared/nav.css" \ "../shared/logo.css" \ "css/landing.css" \ @@ -34,6 +35,7 @@ concat_files \ "../shared/nav.js" \ "../shared/logo.js" \ "../shared/help.js" \ + "../shared/elevation.js" \ "js/landing.js" \ > "$js_raw" diff --git a/landing/template.html b/landing/template.html index 2e9618d..818e2b3 100644 --- a/landing/template.html +++ b/landing/template.html @@ -26,6 +26,12 @@
      + +
      diff --git a/shared/elevation.css b/shared/elevation.css new file mode 100644 index 0000000..9dd31fc --- /dev/null +++ b/shared/elevation.css @@ -0,0 +1,47 @@ +/* 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; +} diff --git a/shared/elevation.js b/shared/elevation.js new file mode 100644 index 0000000..bc6b135 --- /dev/null +++ b/shared/elevation.js @@ -0,0 +1,103 @@ +// 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 = + '' + + ''; + 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 }; +})(); diff --git a/tables/build.sh b/tables/build.sh index 13bdc74..43c4610 100755 --- a/tables/build.sh +++ b/tables/build.sh @@ -21,6 +21,7 @@ concat_files \ "../shared/fonts.css" \ "../shared/base.css" \ "../shared/toast.css" \ + "../shared/elevation.css" \ "../shared/nav.css" \ "../shared/logo.css" \ "css/table.css" \ @@ -41,6 +42,7 @@ concat_files \ "../shared/nav.js" \ "../shared/logo.js" \ "../shared/help.js" \ + "../shared/elevation.js" \ "js/mode.js" \ "js/app.js" \ "js/context.js" \ diff --git a/tables/template.html b/tables/template.html index 6a40043..486d908 100644 --- a/tables/template.html +++ b/tables/template.html @@ -26,6 +26,12 @@
      + +
      diff --git a/transmittal/build.sh b/transmittal/build.sh index 63b9164..4487eb4 100755 --- a/transmittal/build.sh +++ b/transmittal/build.sh @@ -25,6 +25,7 @@ concat_files \ "../shared/fonts.css" \ "../shared/base.css" \ "../shared/toast.css" \ + "../shared/elevation.css" \ "../shared/nav.css" \ "../shared/logo.css" \ "css/base.css" \ @@ -87,6 +88,7 @@ concat_files \ "js/drop-zones.js" \ "js/focus.js" \ "../shared/help.js" \ + "../shared/elevation.js" \ "js/main.js" \ > "$js_raw" diff --git a/transmittal/template.html b/transmittal/template.html index 17d2153..3a006c8 100644 --- a/transmittal/template.html +++ b/transmittal/template.html @@ -43,7 +43,7 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention. JavaScript not available + other tools have "Use Local Directory" here instead) -->
      + +
      diff --git a/zddc/internal/handler/admin_helpers.go b/zddc/internal/handler/admin_helpers.go new file mode 100644 index 0000000..62f6571 --- /dev/null +++ b/zddc/internal/handler/admin_helpers.go @@ -0,0 +1,17 @@ +package handler + +import ( + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// hasAnyAdminScope reports whether p has EFFECTIVE admin authority +// anywhere in the tree. Returns false for an un-elevated principal +// regardless of what the cascade names — the gate is in zddc.Principal +// itself. For the "could this user opt into admin powers?" question +// (elevation-INDEPENDENT), use zddc.HasAnyAdminGrant directly. +func hasAnyAdminScope(fsRoot string, p zddc.Principal) bool { + if !p.Elevated { + return false + } + return zddc.HasAnyAdminGrant(fsRoot, p.Email) +} diff --git a/zddc/internal/handler/paths.go b/zddc/internal/handler/paths.go new file mode 100644 index 0000000..8914635 --- /dev/null +++ b/zddc/internal/handler/paths.go @@ -0,0 +1,85 @@ +package handler + +import ( + "errors" + "path/filepath" + "strings" +) + +// URL ↔ filesystem path math used by several handler files. Pure +// string manipulation — no I/O, no policy decisions — so it lives +// in its own file rather than being attached to any one feature. + +// 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) cannot be addressed through +// admin APIs. 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 callers cannot create + // .foo/.zddc or _bar/.zddc through admin APIs. + 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. Returns "/" for fsRoot itself, otherwise "/". +func urlPathOf(fsRoot, abs string) string { + if abs == fsRoot { + return "/" + } + rel, err := filepath.Rel(fsRoot, abs) + if err != nil { + return "/" + } + return "/" + filepath.ToSlash(rel) +} + +// chainDirs reproduces EffectivePolicy's directory walk so callers can +// label each policy-chain level with the directory it came from. Used +// by the virtual-.zddc body to annotate which ancestor contributed +// which rule. +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 +} diff --git a/zddc/internal/handler/profile_assets.go b/zddc/internal/handler/profile_assets.go new file mode 100644 index 0000000..387910c --- /dev/null +++ b/zddc/internal/handler/profile_assets.go @@ -0,0 +1,71 @@ +package handler + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" +) + +// Custom CSS pipeline. Lets an operator drop `.profile.css` (or the +// legacy `.admin.css`) at the deployment root and have it picked up +// automatically as styling for the profile page. Previously lived +// alongside the retired form editor; kept because the profile page +// still relies on it. + +const ( + profileCustomCSSName = ".profile.css" + adminCustomCSSName = ".admin.css" // legacy fallback +) + +// hasCustomProfileCSS reports whether /.profile.css (or the +// legacy .admin.css) exists. The profile template uses this to decide +// whether to inject the 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 +} + +// profileAssetsPathPrefix is the URL prefix for admin static assets. +// Lived at /.profile/zddc/assets/ during the form-editor era; renamed +// once the form editor retired. The only consumer is the profile page, +// which emits a to /custom.css when an operator has placed one +// at root. +const profileAssetsPathPrefix = ProfilePathPrefix + "/assets" + +// serveProfileAssets handles GET /.profile/assets/. V1 only +// ships `custom.css` (passthrough of /.profile.css when +// present, falling back to /.admin.css); other paths return +// 404 so we don't accidentally expose arbitrary files. The caller +// (profilehandler.go) has already gated on admin scope. +func serveProfileAssets(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, profileAssetsPathPrefix+"/") + 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) + } +}