Compare commits
37 commits
509839dba9
...
d5ce4e1230
| Author | SHA1 | Date | |
|---|---|---|---|
| d5ce4e1230 | |||
| 0d7feb3468 | |||
| a0e467200e | |||
| 14f8780dc5 | |||
| 1b9fec66b3 | |||
| d183de434d | |||
| 0c6396d246 | |||
| d9256050d2 | |||
| 2a05b7716c | |||
| 76087c861c | |||
| ef849ab3fa | |||
| 7f5a54f845 | |||
| 18d3aaebf0 | |||
| 9b20e4451f | |||
| 70591dcfa6 | |||
| bd219afeb7 | |||
| 252d3f173e | |||
| 35a1547d33 | |||
| 4dfbc44d45 | |||
| cd645c53bb | |||
| 9b9d823a67 | |||
| 7b59d82cdb | |||
| fcb8fc6cf1 | |||
| d878bc87e9 | |||
| 51defb115a | |||
| 1e0e403f1e | |||
| 4681f2c358 | |||
| 21f6883157 | |||
| 7e3dbe81aa | |||
| a84bdfdc58 | |||
| bae8e1f79b | |||
| 3ac53fe894 | |||
| 9552b297e7 | |||
| fbe9d11f22 | |||
| 05e37256b7 | |||
| 85e0061d6c | |||
| 3823946d4f |
113 changed files with 7620 additions and 1715 deletions
42
AGENTS.md
42
AGENTS.md
|
|
@ -287,13 +287,13 @@ The build enforces lockstep mechanically (one command bumps all 8). The rules be
|
|||
No install script. Two paths:
|
||||
|
||||
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
|
||||
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive/<party>/incoming/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. **Project shape (May 2026 reshape):** `archive/` is the only physical project-root directory. Six top-level URLs are virtual aggregators: `ssr/mdl/rsk` (tables row-rollups across parties, with a synthesised `$party` source-party column the tables tool renders read-only and strips before write) and `working/staging/reviewing` (browse folder-nav listings of parties with non-empty content; per-party URLs 302-redirect to `archive/<party>/<slot>/`). Mkdir directly at the project root is restricted to `archive` and `_`/`.`-prefixed system names — virtual aggregator names and ad-hoc folders return 409. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
|
||||
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults/`, exported as a `.zddc.zip` via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive/<party>/incoming/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. **Project shape (May 2026 reshape):** `archive/` is the only physical project-root directory. Six top-level URLs are virtual aggregators: `ssr/mdl/rsk` (tables row-rollups across parties, with a synthesised `$party` source-party column the tables tool renders read-only and strips before write) and `working/staging/reviewing` (browse folder-nav listings of parties with non-empty content; per-party URLs 302-redirect to `archive/<party>/<slot>/`). Mkdir directly at the project root is restricted to `archive` and `_`/`.`-prefixed system names — virtual aggregator names and ad-hoc folders return 409. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
|
||||
|
||||
To override a tool's HTML (local-only — no fetch, no channels/versions):
|
||||
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
|
||||
2. Add an `<app>.html` member to the site bundle `<ZDDC_ROOT>/.zddc.zip` (a local zip read server-side via `internal/zipfs`; overrides that tool everywhere, and lets you add new `<name>.html` tools). To route a *different* tool at a path, change `default_tool` / `dir_tool` / `available_tools`.
|
||||
|
||||
Otherwise the embedded build-time copy is served. There is no `apps:` `.zddc` key, no upstream fetch, and no signature verification (all removed). `.zddc.zip` is config, not content: a direct `GET /.zddc.zip` is 404 for everyone; the server reads its members from the filesystem internally.
|
||||
Otherwise the embedded build-time copy is served. There is no `apps:` `.zddc` key, no upstream fetch, and no signature verification (all removed). `.zddc.zip` is config, not content: a direct `GET /.zddc.zip` (bare, `/`-listing, or `/<member>`) is **404 except for a standing config-editor over the bundle's directory** (a subtree admin / `a`-verb holder — no elevation required; `configEditorForBundle` in `cmd/zddc-server/main.go`), who may browse and edit it in place. It is deliberately NOT wide-readable even to plain readers, because one file packs many subtrees' policy — per-level transparency is `ServeZddcFile`'s job. The server reads its members from the filesystem internally regardless.
|
||||
|
||||
Operators audit by reading the `X-ZDDC-Source` response header: `bundle:<app>.html` / `embedded:<app>@<build>` (an on-disk override is served by the static handler with its own headers).
|
||||
|
||||
|
|
@ -406,6 +406,22 @@ Read/aggregate counterpart to the form system. Renders a directory of YAML row f
|
|||
|
||||
**Default-MDL fallback at `archive/<party>/mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive/<party>/mdl/`, presence-based discovery is the rule.
|
||||
|
||||
### Server-injected collections (`apiActions`) — dynamic/virtual tables
|
||||
|
||||
The tables renderer also accepts a **fully pre-assembled, server-injected** `#table-context` (`{title, description, columns[], rows[]}` — used as-is, no directory walk; see `tables/js/context.js` and `handler.injectTableContextObj`). This lets a server handler render a *dynamic or virtual* record collection through the same engine + header chrome as an on-disk table, instead of a bespoke page. When the injected context also carries an **`apiActions`** block, the generic `tables/js/api-actions.js` layer turns the read-only table into a managed collection backed by a REST endpoint — **without touching the file-save/row-ops machinery** (which is bound to `<dir>/*.yaml` row files):
|
||||
|
||||
```
|
||||
apiActions: {
|
||||
create: { url, title?, fixed?{k:v}, fields:[{name,label,placeholder?,type?,required?}], secretField?, secretLabel? },
|
||||
deleteRow: { urlTemplate (with {id} ← row data-url), label?, confirm? },
|
||||
rowNav: true // clicking a row navigates to its data-url (capture-phase)
|
||||
}
|
||||
```
|
||||
|
||||
`create` → modal form → `POST` (date fields → RFC3339; `fixed` adds constants; a `secretField` in the response is shown once); `deleteRow` → per-row button → `DELETE`; both reload on success. It also hides the file-model toolbar buttons (`+ Add row`, `Save`).
|
||||
|
||||
**Consumers:** `/.tokens` (`handler.buildTokensTableContext` → `/.api/tokens`) and `/.profile` (`handler.buildProfileTableContext` → effective access + `POST /.profile/projects` + super-admin diagnostic rows). Per-role correctness is enforced **server-side** — a row/action only appears when the caller is authorized (e.g. profile diagnostics gated on elevated super-admin), so a non-admin's bytes never name a capability they lack. This is the "any dynamic collection is a declarative table, not a bespoke page" primitive from ARCHITECTURE.md's browse-as-shell ADR.
|
||||
|
||||
**Default-MDL columns mirror the tracking-number components** documented at `zddc.varasys.io/reference.html#tracking-numbers`. The default ships the five required components + an optional per-deliverable `suffix`: `originator`, `project`, `discipline`, `type`, `sequence`, `suffix` — each a slot of the deliverable's permanent identifier — plus `title`, `plannedRevision`, `plannedDate`, `status`, `owner`. The project-wide `phase` / `area` components are shipped only as commented-out templates in the default form/table YAML (a project that uses them must enable them on *every* deliverable to keep filenames lexically consistent, so the simplest default omits them). `originator` is **folder-bound**: the cascade's `folder_fields` pins it to the party-folder name, so the form renders it read-only and the server sets it from the path. The form schema accepts free-text on every other component by default; projects narrow the vocabulary via the cascade's `field_codes:` (see below). Operator overrides at `archive/<party>/mdl/{table,form}.yaml` still win atomically over the embedded defaults. Source: `zddc/internal/handler/default-mdl.{table,form}.yaml`.
|
||||
|
||||
**Adding a new table**: create a directory `<dir>/` and drop `table.yaml` (and optionally `form.yaml` for row editing) into it. No code change required. Visit `<dir>/table.html`.
|
||||
|
|
@ -426,7 +442,7 @@ The "records" subset of the tables system carries three guarantees the generic f
|
|||
- `records:` — per-pattern rules keyed by filename basename (literal `ssr.yaml` or glob `*.yaml`). Each entry carries `filename_format` (composition template with `{field}` and `{field?}` placeholders), `field_defaults`, `locked`, `folder_fields`, plus `row_field` + `row_scope_fields` for RSK-style tables-of-rows. Filename-pattern scoping is what lets the SSR rule live at the party-folder level without affecting `mdl/`, `rsk/`, `received/`, etc., siblings.
|
||||
- `folder_fields:` — map of `field → parent-distance` that binds a body field to an ancestor folder name (the folder is the sole source of truth). The map value is how many directories ABOVE the record's own directory the source folder sits (`originator: 1` under `archive/<party>/mdl/` resolves to the `<party>` folder). The server overwrites the body field with the derived name before validation + composition (so a client value can never disagree; a mismatched URL still trips the `filename_format` check), and the form renderer marks the field read-only and pre-fills it.
|
||||
|
||||
Defaults are baked into `defaults.zddc.yaml`; `field_codes:` ships empty (every deployment writes its own vocabulary). The default mdl/rsk records bind `originator` via `folder_fields: {originator: 1}` so the party folder is the originator's source of truth — `originator` is therefore *not* a `field_codes:` entry by default.
|
||||
Defaults are baked into the embedded default tree; `field_codes:` ships empty (every deployment writes its own vocabulary). The default mdl/rsk records bind `originator` via `folder_fields: {originator: 1}` so the party folder is the originator's source of truth — `originator` is therefore *not* a `field_codes:` entry by default.
|
||||
|
||||
**Six server-managed audit fields** are injected on every write and stripped from incoming bodies before validation (snake_case to match `.zddc`'s existing `created_by:`):
|
||||
- `created_at`, `created_by` — stamped on create; preserved untouched on every update
|
||||
|
|
@ -459,7 +475,7 @@ Defaults are baked into `defaults.zddc.yaml`; `field_codes:` ships empty (every
|
|||
- A row whose `originator` differs from its party-folder name is silently rewritten to the folder name on the next write (the folder is the source of truth). Filenames whose originator segment disagrees with the folder will 422 until the file is renamed to match.
|
||||
- Deployments that used the project-wide `phase`/`area` components already supplied a custom `form.yaml` + `.zddc` override (the prior default couldn't compose those slots otherwise), so the phase/area removal from the embedded defaults doesn't affect them.
|
||||
|
||||
Source: `zddc/internal/handler/history.go`, `zddc/internal/zddc/field_codes.go`, `zddc/internal/zddc/walker.go`, `zddc/internal/zddc/cascade.go`, `zddc/internal/zddc/defaults.zddc.yaml`. Tests: `zddc/internal/handler/history_test.go`, `zddc/internal/zddc/field_codes_test.go`.
|
||||
Source: `zddc/internal/handler/history.go`, `zddc/internal/zddc/field_codes.go`, `zddc/internal/zddc/walker.go`, `zddc/internal/zddc/cascade.go`, `zddc/internal/zddc/defaults/`. Tests: `zddc/internal/handler/history_test.go`, `zddc/internal/zddc/field_codes_test.go`.
|
||||
|
||||
## Implementation-vs-dependency policy
|
||||
|
||||
|
|
@ -476,7 +492,7 @@ Go HTTP server sub-project living at `zddc/`. Replaces `caddy file-server --brow
|
|||
|
||||
### Bootstrap config (REQUIRED — unlocks the server)
|
||||
|
||||
zddc-server grants no access to anyone until two operator files are populated. The embedded `defaults.zddc.yaml` ships with empty role members and references those roles throughout its cascade, so a fresh deployment refuses every request until the operator opts in. `zddc-server` logs a startup warning (see `warnIfNoBootstrap` in `zddc/cmd/zddc-server/main.go`) when the root `.zddc` grants nobody anything — skipped under `--no-auth`.
|
||||
zddc-server grants no access to anyone until two operator files are populated. The embedded default tree ships with empty role members and references those roles throughout its cascade, so a fresh deployment refuses every request until the operator opts in. `zddc-server` logs a startup warning (see `warnIfNoBootstrap` in `zddc/cmd/zddc-server/main.go`) when the root `.zddc` grants nobody anything — skipped under `--no-auth`.
|
||||
|
||||
**Root `<ZDDC_ROOT>/.zddc`** — at minimum, declare an admin:
|
||||
|
||||
|
|
@ -485,7 +501,7 @@ admins:
|
|||
- cwitt@burnsmcd.com
|
||||
```
|
||||
|
||||
`admins:` is honored only at the root (subdir admins are read but ignored by `IsAdmin`, see `zddc/internal/zddc/file.go:109-112`). Admins are sudo-style — powers gate on the `zddc-elevate=1` cookie or implicit bearer-token elevation.
|
||||
`admins:` at the **root** confers super-admin (`IsAdmin`, root-only — subdir `admins:` are ignored by `IsAdmin`, see `zddc/internal/zddc/file.go:109-112`). `admins:` at **any level** confers *subtree* admin over that level and below (`IsSubtreeAdmin` / `IsConfigEditor`). Config-edit (editing `.zddc`/roles you administer) is **standing** — no elevation. Only the override powers (WORM bypass, recursive delete, rearrange, out-of-scope) gate on the `zddc-elevate=1` cookie or implicit bearer-token elevation. See "Admin authority: standing config-edit + additive elevation".
|
||||
|
||||
**Per-project `<project>/.zddc`** — populate role members:
|
||||
|
||||
|
|
@ -539,7 +555,7 @@ Pick a role per persona:
|
|||
|
||||
These are NOT interchangeable. A note about which one operators want lives in `cascade.go:13-21` (the `PolicyChain` doc) and the relevant struct fields in `file.go`.
|
||||
|
||||
Run `zddc-server show-defaults` to dump the embedded `defaults.zddc.yaml` with annotated comments — that's the full schema with all the cascade keys (`worm:`, `auto_own:`, `drop_target:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.).
|
||||
Run `zddc-server show-defaults` to export the embedded default tree as a `.zddc.zip` of per-depth files — those files are the full schema with all the cascade keys (`worm:`, `auto_own:`, `drop_target:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.).
|
||||
|
||||
### Build
|
||||
|
||||
|
|
@ -707,17 +723,17 @@ 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)
|
||||
### Admin authority: standing config-edit + additive elevation
|
||||
|
||||
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.
|
||||
Two distinct layers — keep them straight:
|
||||
|
||||
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.
|
||||
**Standing config-edit (no toggle).** Editing configuration is a *standing* permission, not a sudo escape hatch. `zddc.IsConfigEditor(chain, email)` — being a subtree admin (any `admins:` grant on the cascade) OR holding the `a` verb — lets a principal read AND edit the `.zddc` / `.zddc.zip` / role definitions of the subtrees they administer *without elevating*. The decider (`policy.InternalDecider.Allow`) grants `VerbA` on that basis **above the WORM clamp**: config is not WORM-protected data, and `VerbA` only ever authorises config mutation (never write/delete/create of records). Plain `.zddc` reads are gated by directory read-ACL (`ServeZddcFile`), so config is transparent to anyone who can read the path. The blast radius of config-edit is exactly "this subtree and down" — authority cascades downward only (editing `/A/B/.zddc` needs admin over `/A/B`, which never appears in `/A`'s chain), and `ActionAdmin` requires `VerbA`, so a plain `w`/`c` grant can't write a self-promoting `.zddc`.
|
||||
|
||||
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.
|
||||
**Elevation is the additive sudo layer.** It unlocks only "things you otherwise couldn't do": WORM bypass, recursive directory delete, rearranging records, auto-own takeover, acting outside your admin scope, profile admin scaffolds. `IsActiveAdmin = (admin authority on the chain) AND Elevated` is the **single bypass site** in the decider. Carried in a `zddc-elevate=1` **session** cookie (no Max-Age, SameSite=Lax; cleared on `pagehide` so admin mode is scoped to the page you armed it on). Armed by the on-page toggle every tool renders bottom-right *only for `can_elevate` users*, by `?admin=true|false` (honored per-request server-side too), or implicitly for bearer tokens (CLI/mirror can't toggle a cookie; their authority is the bearer's full grant). `shared/elevation.js` applies state in place (no reload — a reload would race the pagehide-clear) and emits a `zddc:elevationchange` event so SPAs (browse) re-fetch verbs.
|
||||
|
||||
`/.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=<true|false>` per request for forensics.
|
||||
Server-side `zddc.Principal{Email, Elevated}` is built once per request by `ACLMiddleware`; `IsAdmin` / `IsSubtreeAdmin` take a `Principal` and stay elevation-gated (they guard the overrides), while `IsConfigEditor` is ungated (the standing config-edit path). `PrincipalFromContext(r)` is the bundling helper. `/.profile/access` exposes `can_elevate` (elevation-independent "does this email have any admin grant anywhere?"); the access log captures `elevated=<true|false>` per request.
|
||||
|
||||
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).
|
||||
Implementation: `zddc/internal/zddc/admin.go` (Principal + `IsConfigEditor`/`IsSubtreeAdmin`/`IsAdmin`), `zddc/internal/policy/policy.go` (decider: `IsActiveAdmin` bypass + standing `VerbA` branch above the WORM clamp), `zddc/internal/handler/middleware.go` (cookie/bearer/`?admin` → ElevatedKey), `shared/elevation.{js,css}` (on-page toggle + ephemeral cookie, concat'd into every tool).
|
||||
|
||||
### Release tagging
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,56 @@ Every ZDDC tool compiles to a single self-contained `.html` file — no servers,
|
|||
|
||||
---
|
||||
|
||||
## ADR: Browse-as-shell with preview-pane plugins (target architecture)
|
||||
|
||||
**Status:** accepted; migrating incrementally (2026-06).
|
||||
|
||||
**Context.** The seven tools have started converging on browse: it already hosts classifier (grid iframe), tables (the table-leaf iframe), forms, and the md / yaml / `.zddc`-form editors in its preview pane, and the header chrome (profile menu + elevation) is shared across every tool. Rather than maintain seven parallel apps, the target is **one shell with a plugin content pane**.
|
||||
|
||||
**Decision.** Browse is the shell — header + tree + preview pane, one top-level document. Content tools render into the preview pane as **plugins**. Server-only behaviour (the account menu, permission-gated affordances) is **progressive enhancement**: it activates when zddc-server serves the page and `/.profile/access` answers, and is simply absent on `file://`. We do **not** iframe browse inside a server-rendered header — browse owns its header and the server enhances it in place. (So "browse opened locally is missing the server header" resolves to "the same header with its server-only items dormant," not a separate page.)
|
||||
|
||||
- **Server mode** is the security boundary: browse fetches ACL-gated listings + per-entry verbs; plugins act through a capability object and can't exceed what the server grants.
|
||||
- **Local mode** (`file://`) is unrestricted: a picked FS-Access directory handle, no server, no account menu — by design.
|
||||
|
||||
**Plugin contract.** A plugin is a module on `window.app.modules`; the shell dispatches to the first whose `handles` returns true:
|
||||
|
||||
```
|
||||
handles(node, ctx) -> bool // claim this node / selection?
|
||||
render(node, container, ctx) // mount into the preview pane (or a host element)
|
||||
dispose?() // tear down (called before switching away)
|
||||
isDirty?() / currentNode?() // optional: unsaved-edit guard + re-render hooks
|
||||
```
|
||||
|
||||
`ctx` is the capability object the shell supplies — the ONLY thing that differs between server and local mode, so a plugin is written once:
|
||||
|
||||
```
|
||||
ctx = {
|
||||
mode: 'server' | 'fs',
|
||||
getArrayBuffer(node), getContentWithVersion(node), // read (etag/lastmod → optimistic concurrency)
|
||||
saveFile(node, bytes, contentType, opts), // write: ACL-enforced (server) / FS-Access (local)
|
||||
cap.has(node, verb), // 'rwcda' subset; '' or unknown offline
|
||||
// server-only (undefined offline): access(path), elevation, history(node)
|
||||
}
|
||||
```
|
||||
|
||||
The md / yaml / `.zddc`-form editors already follow this shape (`handles` / `render` / `isDirty` / `currentNode` + a ctx with `getArrayBuffer` / `getContentWithVersion`); table-leaf and classifier-grid are the same idea via an iframe bridge. Formalising `ctx` makes the contract explicit and lets the heavy tools migrate from iframe to in-pane module — preferred, for shared selection / theme / permission state with no `postMessage`.
|
||||
|
||||
**Migration (incremental; standalone tools keep working throughout).**
|
||||
1. ✓ Editors are in-pane modules; classifier / tables / forms embed in the pane; the shell header carries the profile menu + progressive-enhancement elevation.
|
||||
2. ✓ The two bespoke, chrome-less server pages — `/.tokens` and `/.profile` — now render through the tables engine via server-injected `#table-context` + the generic `apiActions` layer (see AGENTS.md "Server-injected collections"). That's the "dynamic collection → declarative table, not a bespoke page" half proven.
|
||||
3. Fold `archive` into the tree + a listing plugin.
|
||||
4. Make `landing` the shell's root ("no project selected") view — note `landing` is feature-rich (saved groups, multi-select, filters), so this is a *plugin* migration that preserves those, NOT a tables-fication.
|
||||
5. Move `transmittal` into a workflow plugin.
|
||||
6. Flip `default_tool` routing to "browse + plugin X"; retire each standalone `<app>.html` only once its plugin lands. (`archive`/`landing`/`transmittal` are all feature-rich — each fold is a deliberate, scoped effort, not a quick tables swap.)
|
||||
|
||||
**Consequences / tradeoffs.**
|
||||
- Preserves the single-file + offline value: the shell still builds to one `browse.html` that runs from `file://`. Heavy plugins should lazy-load in server mode to keep the bundle reasonable.
|
||||
- The server stays the only security boundary; local is unrestricted by definition.
|
||||
- Seven lockstep release artifacts collapse toward one shell (plus optionally-separate plugins).
|
||||
- Not every tool is a clean pane plugin — `transmittal` is workflow-heavy, `landing` is really the root view — called out above.
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
Every HTML tool follows the same directory layout:
|
||||
|
|
@ -494,8 +544,8 @@ none of them is load-bearing alone.
|
|||
|---|---|---|
|
||||
| Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer <token>` validated against `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` (CLI / scripted callers); or `X-Auth-Request-Email` injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required |
|
||||
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
|
||||
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in `defaults.zddc.yaml` bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego (the bundled `access_federal.rego` is the parent-deny-is-absolute / NIST AC-6 variant) while keeping the same `.zddc` files as input data |
|
||||
| Canonical-folder behaviour | Codify the bilateral exchange-record archetype | All driven by `.zddc` keys (baked into `defaults.zddc.yaml`): `auto_own:` / `auto_own_fenced:` — mkdir here writes a creator-owned `.zddc` (`<email>: rwcda`; fenced adds `acl.inherit:false`); `worm: [principal…]` — write-once-read-many (`w`/`d`/`a` stripped for everyone non-admin, `c` survives only for the listed principals; admins exempt); `virtual:` — never materialise on disk; `drop_target:` — browse shows a drag-drop upload overlay. The defaults put `auto_own` on `working`/`staging`/`archive-party`/`incoming` and `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical convention is unchanged — but an operator can reshape it (rename `received`/`issued`, mark any path WORM, …) without a code change. `zddc/internal/zddc/lookups.go`, `worm.go`, `roles.go`; `defaults.zddc.yaml` |
|
||||
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in default tree bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego (the bundled `access_federal.rego` is the parent-deny-is-absolute / NIST AC-6 variant) while keeping the same `.zddc` files as input data |
|
||||
| Canonical-folder behaviour | Codify the bilateral exchange-record archetype | All driven by `.zddc` keys (baked into the embedded default tree): `auto_own:` / `auto_own_fenced:` — mkdir here writes a creator-owned `.zddc` (`<email>: rwcda`; fenced adds `acl.inherit:false`); `worm: [principal…]` — write-once-read-many (`w`/`d`/`a` stripped for everyone non-admin, `c` survives only for the listed principals; admins exempt); `virtual:` — never materialise on disk; `drop_target:` — browse shows a drag-drop upload overlay. The defaults put `auto_own` on `working`/`staging`/`archive-party`/`incoming` and `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical convention is unchanged — but an operator can reshape it (rename `received`/`issued`, mark any path WORM, …) without a code change. `zddc/internal/zddc/lookups.go`, `worm.go`, `roles.go`; the embedded default tree |
|
||||
| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above |
|
||||
| URL canonicalization | Resolve URL paths to on-disk casing before any layer below sees them | `zddc/internal/fs/resolve.go ResolveCanonical` — case-insensitive lookup with lowercase-wins tiebreak when sibling case variants exist on disk. File and folder names preserve case on disk; the canonicalization is purely URL→FS-name mapping. Virtual prefixes (`.archive`, `.profile`, `.tokens`) flow through verbatim |
|
||||
| Reserved hidden prefixes | Hide operator side-state (caches, dev-shell home dirs) from listings and direct fetch | `.`-prefixed → 404 + listing-filtered; `_`-prefixed → listing-filtered only |
|
||||
|
|
@ -675,11 +725,13 @@ Five permission verbs gate every read and write:
|
|||
|
||||
Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. A leaf allow overrides an ancestor explicit-deny — that's the load-bearing delegation primitive that lets a subtree owner grant access without root-admin involvement. Operators who need the opposite rule (ancestor-deny-absolute, NIST AC-6) deploy OPA with the bundled `access_federal.rego`.
|
||||
|
||||
The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypass: root admins (`IsAdmin`) and subtree admins (`IsSubtreeAdmin`) get unconditional `rwcda` and skip both the cascade and the WORM mask.
|
||||
The `admins:` field (root or any subtree `.zddc`) confers admin authority over that level and below, but it splits into two powers — see the elevation section below:
|
||||
- **Standing config-edit (no elevation):** an admin — or anyone with the `a` verb — may edit the `.zddc`/roles of subtrees they administer. `IsConfigEditor` grants `VerbA` above the WORM clamp; it owns the subtree's policy but cannot write/delete records.
|
||||
- **The unconditional `rwcda` + WORM/cascade bypass requires elevation:** `IsActiveAdmin = admin-on-chain AND Elevated` is the single bypass site. Un-elevated, an admin is a config-editor, not a WORM-bypassing superuser.
|
||||
|
||||
#### Canonical folders, URL routing & the `.zddc` cascade
|
||||
|
||||
There are **no hardcoded folder names** — the canonical project structure is described by a baked-in baseline `.zddc` (`zddc/internal/zddc/defaults.zddc.yaml`), loaded as the bottom layer of the cascade. `zddc-server show-defaults` dumps it; operators override at the on-disk root (or any deeper level) by mirroring the structure and changing what they need (on-disk wins per field). Setting file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer entirely — **including the structural convention (WORM zones, per-user fences, virtual folders)**, not just the default ACLs, so it's a blunt instrument.
|
||||
There are **no hardcoded folder names** — the canonical project structure is described by a baked-in baseline `.zddc` (`zddc/internal/zddc/defaults/`), loaded as the bottom layer of the cascade. `zddc-server show-defaults` exports it as a `.zddc.zip`; operators override at the on-disk root (or any deeper level) by mirroring the structure and changing what they need (on-disk wins per field). Setting file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer entirely — **including the structural convention (WORM zones, per-user fences, virtual folders)**, not just the default ACLs, so it's a blunt instrument.
|
||||
|
||||
**Project shape (after the May 2026 reshape).** `archive/` is the only physical project-root directory. Everything party-scoped lives uniformly under `archive/<party>/{ssr.yaml, mdl/, rsk/, received/, issued/, incoming/, working/<email>/, staging/<batch>/, reviewing/<tracking>/}`. Six sibling top-level URLs are **virtual aggregators**, never on disk:
|
||||
|
||||
|
|
@ -713,9 +765,9 @@ The schema keys that drive built-in behavior:
|
|||
|
||||
**Subtree download.** `GET /some/dir/?zip=1` (the query form works on both `/dir` and `/dir/`) streams an `application/zip` of every readable file under that directory, recursively — `Content-Disposition: attachment; filename="<dir>.zip"`. It's `handler.ServeSubtreeZip`: a `filepath.WalkDir` that ACL-gates each file by the `.zddc` chain of its containing directory (the same per-directory decision cache `serveArchiveListing` uses), skips hidden entries (`.`/`_`-prefixed: `.zddc`, `_template`, `_app`), and adds any `.zip` *file* it meets as opaque bytes (it does **not** recurse into it — that's the navigable-surface above, a different feature). The response is streamed straight onto the `ResponseWriter` (`zip.Store` for already-compressed extensions, `zip.Deflate` otherwise), so a fully-ACL-denied or empty subtree yields a valid empty zip rather than a 403 (a stream can't change status after the headers go out). The browse tool's toolbar **Download (zip)** button hits this for the directory in view in server mode; offline (file://) it walks the picked folder itself with JSZip (with a `confirm()` above ~2000 files / ~500 MB, since the whole tree is buffered in browser memory).
|
||||
|
||||
**WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Admins (root / subtree) bypass entirely — the escape hatch for mis-filed documents. `defaults.zddc.yaml` puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change.
|
||||
**WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Two carve-outs: an **elevated** admin (root / subtree) bypasses the clamp entirely — the escape hatch for mis-filed documents — and a **standing** config-editor keeps `a` (so a subtree admin can edit the `.zddc` that *governs* a WORM zone without elevating; that grants only config mutation, never record write/delete). the embedded default tree puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change.
|
||||
|
||||
**Standard roles.** `defaults.zddc.yaml` references three roles (all shipped empty — a fresh deployment grants nothing until an operator populates them):
|
||||
**Standard roles.** the embedded default tree references three roles (all shipped empty — a fresh deployment grants nothing until an operator populates them):
|
||||
|
||||
- `document_controller` — read/write across a project, `rwc` at `archive/`. When a DC mkdir's `archive/<party>/`, the auto-own `.zddc` grants both their email AND the `document_controller` role `rwcda` at that party (via `auto_own_roles: [document_controller]` in the defaults) — so any peer DC has full authority at every party without needing subtree-admin status. Explicit `rwcd` at `incoming/` and `staging/` shadows the inherited `rwcda` to make the transfer-workflow's `d` requirement obvious. WORM-create principal in `received/issued` via the `worm:` list. NOT a subtree-admin anywhere — admin elevation is reserved for the root `admins:` list (the human escape hatch). Plan-Review approval is part of this role; there is no separate `approver` — two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides rather than baked-in roles.
|
||||
- `project_team` — read-only across the project; their own `archive/<party>/working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ This is a **monorepo of independent tools**, not one application:
|
|||
- `archive/`, `transmittal/`, `classifier/`, `landing/`, `form/`, `tables/`, `browse/` — seven self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). `form/` is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); `tables/` is its read/aggregate counterpart, rendering a directory of YAML rows as a sortable table; `browse/` is the file-tree navigator and **also hosts the in-place markdown editor** (`browse/js/preview-markdown.js` — Toast UI Editor + YAML front-matter pane + on-demand server-side MD→DOCX/HTML/PDF download buttons). A dedicated `mdedit/` tool used to live alongside these but has been retired. See AGENTS.md "Form-data system" / "Tables system" and ARCHITECTURE.md "Form Renderer".
|
||||
- `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Two deployment shapes from the same binary: (1) **master** — owns a file tree under `ZDDC_ROOT`, applies `.zddc` ACL cascades, serves files / app HTML / archive listings. Two auth paths on master: `Authorization: Bearer <token>` validated against self-issued tokens at `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` for CLI/scripted callers, or `X-Auth-Request-Email` injected by an upstream proxy for browser sessions. Self-service token UI at `/.tokens` + JSON API at `/.api/tokens`. (2) **client** — when `--upstream <url>` is set, the binary becomes a downstream proxy/cache/mirror (`zddc/internal/cache/`); master-side machinery is bypassed and `--root` becomes the cache directory. Three sub-modes via `--mode proxy|cache|mirror` (mirror is phase 3). Cache layout is a normal ZDDC root, so the cache dir can be served as a plain master if you unset `--upstream`. Marker file `.zddc-upstream` records provenance. `--no-auth` skips ACL enforcement entirely on this instance (distinct from `--insecure` which only relaxes the no-root-`.zddc` startup check); `--skip-tls-verify` is a separate flag for self-signed upstream certs. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time.
|
||||
- `shared/` — CSS (`base.css`, `fonts.css` + base64-inlined woff2 under `fonts/`, `nav.css`, `logo.css`, `toast.css`) plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `zddc-source.js`, `zip-source.js`, `theme.js`, `toast.js`, `nav.js`, `logo.js`, `help.js`, `preview-lib.js`) and vendored libs (`vendor/`: jszip, xlsx, utif, docx-preview, toastui-editor) — each tool's `build.sh` concatenates the subset it needs. Also `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers). See AGENTS.md "Shared modules" for the full inventory.
|
||||
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all seven HTML tools baked in via `//go:embed` (compile-time default). **Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded folder names** — a baked-in `defaults.zddc.yaml` (dump it: `zddc-server show-defaults`) declares, via a recursive `paths:` tree, per-folder `default_tool` (served at `<dir>` — `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive/<party>/incoming/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at root), `dir_tool` (served at `<dir>/`; defaults to `browse`), `available_tools`, plus the canonical-folder behaviour keys (`auto_own`, `worm:`, `virtual`, `drop_target`); operators override at any level. **Project shape (May 2026 reshape):** `archive/` is the only physical project-root directory. Six top-level URLs are virtual aggregators: `ssr/mdl/rsk` (tables rollups across parties with a synthesized `$party` source-party column) and `working/staging/reviewing` (browse folder-nav listings of parties with non-empty content; per-party clicks 302-redirect to `archive/<party>/<slot>/`). Mkdir at project root is restricted to `archive` + `_`/`.`-prefixed system names; the six virtual names are rejected with 409. A `.zip` file is also a navigable directory (`GET …/Foo.zip/` → member listing; `…/Foo.zip/m.pdf` → that member); `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* via a `.zddc apps:` entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`; or drop a real `.html` at any path. See AGENTS.md "URL handling"/"Install model" and ARCHITECTURE.md "Canonical folders, URL routing & the `.zddc` cascade".
|
||||
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all seven HTML tools baked in via `//go:embed` (compile-time default). **Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded folder names** — a baked-in default tree (export it as a `.zddc.zip`: `zddc-server show-defaults`) declares, via a recursive `paths:` tree, per-folder `default_tool` (served at `<dir>` — `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive/<party>/incoming/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at root), `dir_tool` (served at `<dir>/`; defaults to `browse`), `available_tools`, plus the canonical-folder behaviour keys (`auto_own`, `worm:`, `virtual`, `drop_target`); operators override at any level. **Project shape (May 2026 reshape):** `archive/` is the only physical project-root directory. Six top-level URLs are virtual aggregators: `ssr/mdl/rsk` (tables rollups across parties with a synthesized `$party` source-party column) and `working/staging/reviewing` (browse folder-nav listings of parties with non-empty content; per-party clicks 302-redirect to `archive/<party>/<slot>/`). Mkdir at project root is restricted to `archive` + `_`/`.`-prefixed system names; the six virtual names are rejected with 409. A `.zip` file is also a navigable directory (`GET …/Foo.zip/` → member listing; `…/Foo.zip/m.pdf` → that member); `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* by dropping a real `<app>.html` at the path or adding an `<app>.html` member to a `.zddc.zip` (resolution: on-disk file → `.zddc.zip` member → embedded; no fetch, no `apps:` key — removed). See AGENTS.md "URL handling"/"Install model" and ARCHITECTURE.md "Canonical folders, URL routing & the `.zddc` cascade".
|
||||
- `helm/` — example Helm charts for zddc-server. Three flavors: `zddc-server-prod/` (production master), `zddc-server-dev/` (development master with OverlayFS isolation), `zddc-server-cache/` (downstream client running in proxy/cache/mirror mode against an upstream master, with bearer token from a Kubernetes Secret). All compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo — the cache chart references a separately-created Secret for the bearer token.
|
||||
- `tests/` — Playwright specs (Chromium only, requires File System Access API). `tests/schema.spec.js` validates `transmittal.schema.json` against canonical fixtures via `ajv` (only dev dep besides Playwright)
|
||||
|
||||
|
|
@ -88,6 +88,9 @@ No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSI
|
|||
- **`</` in JS string/template literals breaks inline `<script>`** 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.
|
||||
- **Admin authority is layered: standing config-edit + additive sudo overrides.** Two distinct things — don't conflate them:
|
||||
- **Config-edit is STANDING (no toggle).** A subtree admin (named in any `admins:` on the cascade) or anyone holding the `a` verb may *read and edit* the `.zddc` / `.zddc.zip` / role definitions of subtrees they administer without elevating — `zddc.IsConfigEditor(chain, email)`. The decider (`policy.InternalDecider.Allow`) grants `VerbA` on that basis *above* the WORM clamp (config isn't WORM-protected data, and `VerbA` only ever authorises config mutation, never write/delete of records). "Admin of X = owns X's policy," bounded to that subtree (authority cascades down only, never up). Plain `.zddc` reads are governed by directory read-ACL (`ServeZddcFile`), so **config is transparent** to anyone who can read the path.
|
||||
- **Elevation is the sudo escape hatch — purely ADDITIVE.** It only unlocks "things you otherwise couldn't do": WORM bypass, recursive directory delete, rearranging records, acting outside your admin scope. `IsActiveAdmin = (admin authority on the chain) AND Elevated` is the single bypass site in the decider; `IsAdmin`/`IsSubtreeAdmin` stay elevation-gated (they guard the overrides). Carried in the `zddc-elevate=1` **session** cookie (no Max-Age; cleared on `pagehide`, so admin mode is per-page), armed by the on-page toggle every tool renders bottom-right *only for `can_elevate` users*, by `?admin=true|false`, or implicitly for bearer tokens. `shared/elevation.js` applies it in place + emits `zddc:elevationchange` (browse re-fetches verbs); `handler.ACLMiddleware` builds `zddc.Principal{Email, Elevated}` per request. `/.profile/access` exposes `can_elevate`; the access-log captures `elevated` per request.
|
||||
- **Secrets stay locked:** `.zddc.d/` (bearer tokens, access logs) is reserved regardless of read-ACL. The `.zddc.zip` bundle is visible+editable to config-editors of its directory (not wide-read — one file packs many subtrees' policy).
|
||||
- **Worktrees live at `~/src/zddc-<branch>`.** 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.
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ The name "Zero Day Document Control" comes from the convention itself — adopt
|
|||
| **Browse** | File-tree navigator with previews and an in-place markdown editor (YAML front matter, outline, server-side DOCX/HTML/PDF download); the everywhere-available companion to the Archive Browser when you want plain folder navigation rather than tracking-number aggregation. |
|
||||
| **Landing** | The project picker served at the deployment root of a `zddc-server`. |
|
||||
|
||||
Each tool is published in three channels (stable, beta, alpha) as static files served from <https://zddc.varasys.io/releases/>. **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Which tool a directory URL serves is driven by the `.zddc` cascade: a baked-in `defaults.zddc.yaml` (dump it with `zddc-server show-defaults`) declares, per folder, `default_tool` (the no-slash form — archive under `archive/`, transmittal under `staging/`, browse under `working/`+`reviewing/` (browse hosts the in-place markdown editor), classifier under `incoming/`, tables at `archive/<party>/mdl`, landing at root) and `dir_tool` (the trailing-slash form; defaults to `browse`); operators override at any level. A `.zip` file is also a navigable directory (`GET …/Foo.zip/`), and `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path) — fetched once and cached in `<ZDDC_ROOT>/_app/` — or drop a real `.html` file at any path.
|
||||
Each tool is published in three channels (stable, beta, alpha) as static files served from <https://zddc.varasys.io/releases/>. **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Which tool a directory URL serves is driven by the `.zddc` cascade: a baked-in default tree (export it as a `.zddc.zip` with `zddc-server show-defaults`) declares, per folder, `default_tool` (the no-slash form — archive under `archive/`, transmittal under `staging/`, browse under `working/`+`reviewing/` (browse hosts the in-place markdown editor), classifier under `incoming/`, tables at `archive/<party>/mdl`, landing at root) and `dir_tool` (the trailing-slash form; defaults to `browse`); operators override at any level. A `.zip` file is also a navigable directory (`GET …/Foo.zip/`), and `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* by dropping a real `<app>.html` file at the path or adding an `<app>.html` member to a `.zddc.zip` (resolution order: on-disk file → `.zddc.zip` member → embedded; no fetch).
|
||||
|
||||
## Deploy: bootstrap config
|
||||
|
||||
> **A fresh `zddc-server` deployment grants no access to anyone until two config files are populated.** Without them, the server runs but every request returns 403. The embedded `defaults.zddc.yaml` ships with empty role members so deployments must opt-in to authorize anyone.
|
||||
> **A fresh `zddc-server` deployment grants no access to anyone until two config files are populated.** Without them, the server runs but every request returns 403. The embedded default tree ships with empty role members so deployments must opt-in to authorize anyone.
|
||||
|
||||
**Step 1.** At the master root, create `/.zddc` (i.e. `<ZDDC_ROOT>/.zddc`) naming at least one admin:
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ acl:
|
|||
|
||||
Bits are any subset of `r w c d a` (read / write / create / delete / admin); empty string is an explicit deny. Principals are emails, globs like `*@domain.com`, or role names (anything without an `@`).
|
||||
|
||||
`zddc-server` prints a startup warning when the root `.zddc` grants nobody anything — watch for it on first boot. For the full schema, run `zddc-server show-defaults` (dumps the embedded `defaults.zddc.yaml` with annotated comments).
|
||||
`zddc-server` prints a startup warning when the root `.zddc` grants nobody anything — watch for it on first boot. For the full schema, run `zddc-server show-defaults` (exports the embedded default tree as a `.zddc.zip`).
|
||||
|
||||
## File-naming convention
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ concat_files \
|
|||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/base.css" \
|
||||
"css/layout.css" \
|
||||
|
|
@ -64,6 +65,7 @@ concat_files \
|
|||
"js/app.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/profile-menu.js" \
|
||||
"../shared/cap.js" \
|
||||
> "$js_raw"
|
||||
|
||||
|
|
|
|||
|
|
@ -36,12 +36,6 @@
|
|||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||
</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">?</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ concat_files \
|
|||
"../shared/vendor/codemirror-yaml.min.css" \
|
||||
"../shared/context-menu.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
"css/base.css" \
|
||||
"css/tree.css" \
|
||||
"css/preview-yaml.css" \
|
||||
|
|
@ -59,6 +60,7 @@ concat_files \
|
|||
"../shared/preview-lib.js" \
|
||||
"../shared/context-menu.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/profile-menu.js" \
|
||||
"../shared/cap.js" \
|
||||
"../shared/icons.js" \
|
||||
"../shared/zddc-source.js" \
|
||||
|
|
@ -71,6 +73,7 @@ concat_files \
|
|||
"js/preview.js" \
|
||||
"js/preview-markdown.js" \
|
||||
"js/preview-yaml.js" \
|
||||
"js/preview-zddc-form.js" \
|
||||
"js/hovercard.js" \
|
||||
"js/grid.js" \
|
||||
"js/upload.js" \
|
||||
|
|
|
|||
|
|
@ -29,6 +29,16 @@
|
|||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
/* The ".zddc schema" badge is clickable — it opens the full JSON Schema. */
|
||||
.yaml-shell__schema--link {
|
||||
cursor: pointer;
|
||||
}
|
||||
.yaml-shell__schema--link:hover,
|
||||
.yaml-shell__schema--link:focus-visible {
|
||||
background: var(--primary);
|
||||
color: var(--bg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* CodeMirror has to fill the grid cell. The vendored CSS sets
|
||||
`height: 300px` by default — we override to 100% so it grows with
|
||||
|
|
|
|||
|
|
@ -366,7 +366,7 @@ body {
|
|||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.4rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.tree-pane__controls .tp-control {
|
||||
display: inline-flex;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,32 @@
|
|||
|
||||
function events() { return window.app.modules.events; }
|
||||
|
||||
// Canonical document-conversion matrix — mirrors zddc/internal/convert
|
||||
// Convert(): which target formats a given source extension can be exported
|
||||
// to. PDF is markdown-only (md→pdf) because the server has no docx→pdf /
|
||||
// html→pdf path. This is the SINGLE source of truth for both the Export
|
||||
// context-menu (download.exportTargets) and the markdown editor's
|
||||
// DOCX/HTML/PDF buttons (preview-markdown.js), so the two never drift.
|
||||
var EXPORT_MATRIX = {
|
||||
md: ['docx', 'html', 'pdf'],
|
||||
docx: ['md', 'html'],
|
||||
html: ['md', 'docx']
|
||||
};
|
||||
|
||||
// exportTargets returns the formats a file of extension `ext` can be
|
||||
// exported to (excludes the source format itself), or [] if `ext` is not a
|
||||
// convertible source. Case-insensitive.
|
||||
function exportTargets(ext) {
|
||||
return EXPORT_MATRIX[String(ext || '').toLowerCase()] || [];
|
||||
}
|
||||
|
||||
// convertUrl maps a source path/URL to its sibling virtual-conversion URL
|
||||
// (foo.md → foo.pdf). zddc-server recognises the sibling-extension pattern
|
||||
// and converts on the fly. Shared by exportFile and the editor buttons.
|
||||
function convertUrl(path, fmt) {
|
||||
return String(path || '').replace(/\.[^./]+$/, '') + '.' + fmt;
|
||||
}
|
||||
|
||||
function isHiddenName(name) {
|
||||
return name.length === 0 || name[0] === '.' || name[0] === '_';
|
||||
}
|
||||
|
|
@ -202,8 +228,8 @@
|
|||
events().statusError('No path for ' + node.name);
|
||||
return;
|
||||
}
|
||||
var url = path.replace(/\.[^./]+$/, '') + '.' + fmt;
|
||||
var name = node.name.replace(/\.[^./]+$/, '') + '.' + fmt;
|
||||
var url = convertUrl(path, fmt);
|
||||
var name = convertUrl(node.name, fmt);
|
||||
events().statusInfo('Exporting ' + name + '…');
|
||||
downloadUrl(name, url);
|
||||
setTimeout(function () { events().statusClear(); }, 2500);
|
||||
|
|
@ -212,6 +238,8 @@
|
|||
window.app.modules.download = {
|
||||
downloadFile: downloadFile,
|
||||
downloadFolder: downloadFolder,
|
||||
exportFile: exportFile
|
||||
exportFile: exportFile,
|
||||
exportTargets: exportTargets,
|
||||
convertUrl: convertUrl
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -88,21 +88,6 @@
|
|||
refresh.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
// Toolbar New buttons: enabled when there's a writable target, and in
|
||||
// server mode greyed (with a why-tooltip) when the scope lacks the
|
||||
// create verb. Mirrors the menu's create-gate.
|
||||
var canCreate = canCreateHere();
|
||||
var lacksCreateVerb = state.source === 'server'
|
||||
&& state.scopeAccess && typeof state.scopeAccess.path_verbs === 'string'
|
||||
&& state.scopeAccess.path_verbs.indexOf('c') === -1;
|
||||
['newFolderBtn', 'newFileBtn'].forEach(function (id) {
|
||||
var b = document.getElementById(id);
|
||||
if (!b) return;
|
||||
var off = !canCreate || lacksCreateVerb;
|
||||
b.disabled = off;
|
||||
b.title = lacksCreateVerb ? 'You don’t have create access here.'
|
||||
: (!canCreate ? 'Open a folder to create files here.' : '');
|
||||
});
|
||||
}
|
||||
|
||||
// syncURLToSelection reflects the current scope + selected node +
|
||||
|
|
@ -225,18 +210,22 @@
|
|||
var refresh = document.getElementById('refreshHeaderBtn');
|
||||
if (refresh) refresh.addEventListener('click', refreshListing);
|
||||
|
||||
// ── Tree-pane toolbar: New folder / New file, Sort, Show hidden ──
|
||||
// View settings live on the toolbar (not in per-row right-click
|
||||
// menus); create has a discoverable affordance here now that file
|
||||
// rows no longer offer it.
|
||||
var newFolderBtn = document.getElementById('newFolderBtn');
|
||||
if (newFolderBtn) newFolderBtn.addEventListener('click', function () {
|
||||
createInDir(state.currentPath || '/', 'folder');
|
||||
});
|
||||
var newFileBtn = document.getElementById('newFileBtn');
|
||||
if (newFileBtn) newFileBtn.addEventListener('click', function () {
|
||||
createInDir(state.currentPath || '/', 'markdown');
|
||||
// Admin mode (shared/elevation.js) flipped on this page. Listing
|
||||
// verbs + editor affordances (canSave) are computed against the
|
||||
// server WITH the elevation cookie, so re-fetch the listing (which
|
||||
// re-runs prefetchScopeAccess) and re-render the open preview —
|
||||
// restoreState only restores the highlight, not the pane contents.
|
||||
window.addEventListener('zddc:elevationchange', async function () {
|
||||
if (state.source !== 'server') return; // FS mode has no server elevation
|
||||
await refreshListing();
|
||||
var node = state.lastPreviewedNodeId && state.nodes.get(state.lastPreviewedNodeId);
|
||||
var p = window.app.modules.preview;
|
||||
if (node && !node.isDir && p && p.showFilePreview) p.showFilePreview(node);
|
||||
});
|
||||
|
||||
// ── Tree-pane toolbar: Sort + Show hidden ──────────────────────
|
||||
// View settings only. Create actions (new folder / file) live in
|
||||
// the right-click context menu, not the toolbar.
|
||||
var sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
// Reflect current state, then drive setSortExplicit on change.
|
||||
|
|
@ -364,7 +353,10 @@
|
|||
var node = state.nodes.get(id);
|
||||
if (!node) return;
|
||||
|
||||
var isExpandable = row.dataset.isdir === 'true' || row.dataset.iszip === 'true';
|
||||
// Table-leaf dirs (mdl/rsk/ssr) are NOT expandable — they fall
|
||||
// through to the preview path, which opens the tables tool.
|
||||
var isExpandable = (row.dataset.isdir === 'true' || row.dataset.iszip === 'true')
|
||||
&& row.dataset.tableleaf !== 'true';
|
||||
|
||||
if (isExpandable) {
|
||||
e.preventDefault();
|
||||
|
|
@ -430,6 +422,7 @@
|
|||
var row = e.target.closest('.tree-row');
|
||||
if (!row) return;
|
||||
if (row.dataset.isdir !== 'true') return;
|
||||
if (row.dataset.tableleaf === 'true') return; // leaf: single-click previews
|
||||
var id = parseInt(row.dataset.id, 10);
|
||||
var node = state.nodes.get(id);
|
||||
if (!node) return;
|
||||
|
|
@ -501,7 +494,10 @@
|
|||
var curIdx = visible.indexOf(state.selectedId);
|
||||
var node = state.selectedId != null
|
||||
? state.nodes.get(state.selectedId) : null;
|
||||
var expandable = !!(node && (node.isDir || node.isZip));
|
||||
// Table-leaf dirs aren't expandable: Enter/Space previews them
|
||||
// (opens the table) rather than toggling.
|
||||
var expandable = !!(node && (node.isDir || node.isZip)
|
||||
&& !window.app.modules.util.isTableLeaf(node));
|
||||
var nextId = null;
|
||||
var previewModule = previewMod();
|
||||
|
||||
|
|
@ -1125,11 +1121,12 @@
|
|||
|
||||
// View mode is URL-driven, not UI-driven.
|
||||
//
|
||||
// ?view=grid → grid mode (only honored where classifier is
|
||||
// available; otherwise falls back to browse)
|
||||
// ?view=browse → browse mode (always)
|
||||
// default → path-based: grid when inside an incoming/
|
||||
// subtree, browse everywhere else
|
||||
// ?view=grid → embedded-tool view (only honored where the cascade's
|
||||
// default_tool is an embeddable full-page tool —
|
||||
// classifier/transmittal/archive; else falls back to browse)
|
||||
// ?view=browse → browse listing (always)
|
||||
// default → embedded-tool view when the dir's default_tool is one
|
||||
// of those tools, browse listing everywhere else
|
||||
//
|
||||
// resolveViewMode reads the current location and returns the mode
|
||||
// to render; applyResolvedViewMode toggles the panes accordingly.
|
||||
|
|
@ -1138,10 +1135,10 @@
|
|||
var qs = new URLSearchParams(window.location.search);
|
||||
var explicit = (qs.get('view') || '').toLowerCase();
|
||||
var grid = window.app.modules.grid;
|
||||
var classifierHere = !!(grid && grid.availableHere && grid.availableHere());
|
||||
if (explicit === 'grid') return classifierHere ? 'grid' : 'browse';
|
||||
var toolHere = !!(grid && grid.availableHere && grid.availableHere());
|
||||
if (explicit === 'grid') return toolHere ? 'grid' : 'browse';
|
||||
if (explicit === 'browse') return 'browse';
|
||||
return classifierHere ? 'grid' : 'browse';
|
||||
return toolHere ? 'grid' : 'browse';
|
||||
}
|
||||
|
||||
function applyResolvedViewMode() {
|
||||
|
|
|
|||
|
|
@ -1,48 +1,53 @@
|
|||
// grid.js — "Grid mode" plugin for browse. Loads the classifier tool
|
||||
// as an iframe scoped to the current directory so users get classifier's
|
||||
// full bulk-rename workflow without leaving browse.
|
||||
// grid.js — in-pane tool embed for browse (the browse-as-shell bridge; see
|
||||
// ARCHITECTURE.md's ADR). Loads a heavy, full-page tool as an iframe scoped to
|
||||
// the current directory so the user gets that tool's full workflow without
|
||||
// leaving the browse shell. browse stays the top-level app; the cascade's
|
||||
// default_tool decides which tool embeds here.
|
||||
//
|
||||
// Availability: the cascade decides. Grid auto-activates wherever the
|
||||
// .zddc cascade resolves default_tool=classifier (defaults.zddc.yaml
|
||||
// declares this for archive/<party>/incoming/). Operators can extend
|
||||
// — e.g. setting default_tool=classifier on a custom dir activates
|
||||
// grid mode there too — without touching this code.
|
||||
//
|
||||
// Iframe src resolution: <currentDirURL>/classifier.html. Iframe
|
||||
// embedding only works in server mode; file:// pages don't get the
|
||||
// Grid toggle.
|
||||
// Availability: the cascade decides — `state.scopeDefaultTool` (the
|
||||
// X-ZDDC-Default-Tool header) must name one of the EMBEDDABLE full-page tools:
|
||||
// classifier (archive/<party>/incoming/), transmittal (…/staging/), archive
|
||||
// (the archive index). tables/forms embed in the preview pane instead
|
||||
// (table-leaf / form view); landing/browse don't self-embed. Operators extend
|
||||
// by setting default_tool on a dir — no code change. Iframe src:
|
||||
// <currentDirURL>/<tool>.html. Server mode only (file:// has no server).
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var state = window.app.state;
|
||||
var mounted = false;
|
||||
|
||||
function classifierAvailableHere() {
|
||||
// state.scopeDefaultTool is set by the loader from the
|
||||
// X-ZDDC-Default-Tool response header on every listing fetch.
|
||||
// Grid mode is meaningful exactly where the cascade picks
|
||||
// classifier as the default — no client-side path matching.
|
||||
return state.scopeDefaultTool === 'classifier';
|
||||
// Full-page tools that embed in the gridView pane when they're the dir's
|
||||
// default_tool. (tables/form embed in the preview pane; landing/browse are
|
||||
// not in-pane embeds.)
|
||||
var EMBEDDABLE = { classifier: 1, transmittal: 1, archive: 1 };
|
||||
|
||||
// The cascade-resolved default tool for the current dir when it's an
|
||||
// embeddable full-page tool; "" otherwise.
|
||||
function embedToolHere() {
|
||||
var t = state.scopeDefaultTool;
|
||||
return (t && EMBEDDABLE[t]) ? t : '';
|
||||
}
|
||||
|
||||
function activate() {
|
||||
var host = document.getElementById('gridView');
|
||||
if (!host) return;
|
||||
if (mounted) return;
|
||||
if (state.source !== 'server' || !classifierAvailableHere()) return;
|
||||
var tool = embedToolHere();
|
||||
if (state.source !== 'server' || !tool) return;
|
||||
|
||||
// Compute the iframe src: current page's directory + classifier.html.
|
||||
// Compute the iframe src: current page's directory + <tool>.html.
|
||||
var pathname = window.location.pathname || '/';
|
||||
if (!pathname.endsWith('/')) {
|
||||
var lastSlash = pathname.lastIndexOf('/');
|
||||
pathname = lastSlash >= 0 ? pathname.substring(0, lastSlash + 1) : '/';
|
||||
}
|
||||
var src = pathname + 'classifier.html';
|
||||
var src = pathname + tool + '.html';
|
||||
|
||||
host.innerHTML = '';
|
||||
var frame = document.createElement('iframe');
|
||||
frame.src = src;
|
||||
frame.title = 'ZDDC Classifier (Grid mode)';
|
||||
frame.title = 'ZDDC ' + tool;
|
||||
frame.style.cssText = 'width:100%;height:100%;border:0;display:block;'
|
||||
+ 'background:var(--bg);';
|
||||
host.appendChild(frame);
|
||||
|
|
@ -61,9 +66,12 @@
|
|||
window.app.modules.grid = {
|
||||
activate: activate,
|
||||
reset: reset,
|
||||
// Hook for events.js to show/hide the Grid toggle button.
|
||||
// Hook for events.js's view-mode resolution: is an embeddable tool the
|
||||
// default here?
|
||||
availableHere: function () {
|
||||
return state.source === 'server' && classifierAvailableHere();
|
||||
}
|
||||
return state.source === 'server' && !!embedToolHere();
|
||||
},
|
||||
// The embeddable tool name (or "") — lets the shell label the view.
|
||||
toolHere: embedToolHere
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -68,6 +68,11 @@
|
|||
// context-menu affordance (server mode only — offline has no
|
||||
// authenticated identity to attribute saves to).
|
||||
history: !!e.history,
|
||||
// Server-computed: cascade-resolved default tool for a DIRECTORY
|
||||
// entry (e.g. "tables", "classifier"). Browse renders a dir whose
|
||||
// defaultTool=="tables" (mdl/rsk/ssr) as a click-to-table leaf —
|
||||
// the table opens in the preview pane instead of the dir expanding.
|
||||
defaultTool: (typeof e.default_tool === 'string') ? e.default_tool : '',
|
||||
// FS-API specific (null in server mode):
|
||||
handle: null
|
||||
};
|
||||
|
|
|
|||
|
|
@ -47,9 +47,16 @@
|
|||
function appliesToFolderLike(node) { return !!(node && (node.isDir || node.isZip)); }
|
||||
function appliesToFile(node) { return !!(node && !node.isDir && !node.isZip); }
|
||||
|
||||
// Formats the Export submenu offers for a file (server-side conversion):
|
||||
// a file of one of these extensions can be exported as the other two.
|
||||
var EXPORT_FORMATS = ['md', 'docx', 'html'];
|
||||
// The Export submenu's convertible-format set comes from the download
|
||||
// module's canonical matrix (download.exportTargets), which mirrors the
|
||||
// server's conversion matrix — the single source of truth shared with the
|
||||
// markdown editor's DOCX/HTML/PDF buttons. exportTargets(ext) returns the
|
||||
// target formats for a source extension (e.g. md → docx, html, pdf), or []
|
||||
// when the extension isn't a convertible source.
|
||||
function exportTargets(ext) {
|
||||
var d = window.app.modules.download;
|
||||
return (d && d.exportTargets) ? d.exportTargets(ext) : [];
|
||||
}
|
||||
function cap() { return window.zddc && window.zddc.cap; }
|
||||
|
||||
function canVerb(node, verb) {
|
||||
|
|
@ -181,9 +188,10 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
// Export submenu: a folder offers ".zip" (both modes); a md/docx/html
|
||||
// file offers the OTHER two formats (server-side conversion, so
|
||||
// server mode only). A zip is already an archive — no Export.
|
||||
// Export submenu: a folder offers ".zip" (both modes); a convertible
|
||||
// file (md/docx/html) offers its server-side conversion targets —
|
||||
// md → docx/html/pdf, docx → md/html, html → md/docx (server mode
|
||||
// only). A zip is already an archive — no Export.
|
||||
id: 'export', group: 'io', surfaces: ['row'],
|
||||
label: 'Export',
|
||||
appliesTo: function (ctx) {
|
||||
|
|
@ -191,7 +199,7 @@
|
|||
if (!n || n.virtual) return false;
|
||||
if (n.isDir) return true;
|
||||
if (n.isZip) return false;
|
||||
return isServer() && EXPORT_FORMATS.indexOf((n.ext || '').toLowerCase()) !== -1;
|
||||
return isServer() && exportTargets(n.ext).length > 0;
|
||||
},
|
||||
items: function (ctx) {
|
||||
var n = ctx.node;
|
||||
|
|
@ -200,8 +208,8 @@
|
|||
if (n.isDir) {
|
||||
return [{ label: '.zip', action: function () { d.downloadFolder(n); } }];
|
||||
}
|
||||
var cur = (n.ext || '').toLowerCase();
|
||||
return EXPORT_FORMATS.filter(function (f) { return f !== cur; }).map(function (fmt) {
|
||||
// exportTargets already excludes the source format.
|
||||
return exportTargets(n.ext).map(function (fmt) {
|
||||
return { label: '.' + fmt, action: function () { d.exportFile(n, fmt); } };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,38 @@
|
|||
}
|
||||
|
||||
// ── Front matter ────────────────────────────────────────────────────────
|
||||
// Cached recognised-front-matter placeholder, fetched once from the server
|
||||
// (/.api/frontmatter — the single source of truth that mirrors the
|
||||
// converter's RecognizedFrontMatter). null = not yet fetched; '' = fetched
|
||||
// empty / unavailable. The promise dedupes concurrent fetches.
|
||||
var fmPlaceholder = null;
|
||||
var fmPlaceholderPromise = null;
|
||||
|
||||
// applyFrontMatterPlaceholder sets the textarea placeholder to the server's
|
||||
// recognised-field hint, in server mode only. Async + best-effort: a failed
|
||||
// fetch leaves the pane blank (no placeholder), never an error.
|
||||
function applyFrontMatterPlaceholder(textarea) {
|
||||
var st = window.app && window.app.state;
|
||||
if (!st || st.source !== 'server') return;
|
||||
if (fmPlaceholder !== null) {
|
||||
textarea.placeholder = fmPlaceholder;
|
||||
return;
|
||||
}
|
||||
if (!fmPlaceholderPromise) {
|
||||
fmPlaceholderPromise = fetch('/.api/frontmatter', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) { fmPlaceholder = (j && j.placeholder) || ''; })
|
||||
.catch(function () { fmPlaceholder = ''; });
|
||||
}
|
||||
fmPlaceholderPromise.then(function () {
|
||||
// Only apply if this textarea is still in the DOM (user may have
|
||||
// switched files before the fetch resolved).
|
||||
if (textarea.isConnected) textarea.placeholder = fmPlaceholder;
|
||||
});
|
||||
}
|
||||
|
||||
// Lightweight YAML front-matter parser. Same envelope as mdedit's:
|
||||
// `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays.
|
||||
|
||||
|
|
@ -283,9 +315,12 @@
|
|||
}
|
||||
|
||||
var isZipMemberNode = util.isZipMemberNode;
|
||||
var isEditableZipMember = util.isEditableZipMember;
|
||||
|
||||
function canSave(node) {
|
||||
if (isZipMemberNode(node)) return false;
|
||||
// A .zddc.zip bundle member is saveable iff editable (elevated admin) —
|
||||
// the server's ServeZipWrite is the gate; other zip members read-only.
|
||||
if (isZipMemberNode(node)) return isEditableZipMember(node);
|
||||
// Server-computed authority gate. The listing's verbs string
|
||||
// tells us whether a PUT to this entry would be allowed —
|
||||
// false here means the file API would 403, so we mount in
|
||||
|
|
@ -368,12 +403,29 @@
|
|||
fmTextarea.spellcheck = false;
|
||||
fmTextarea.autocapitalize = 'off';
|
||||
fmTextarea.autocomplete = 'off';
|
||||
// No placeholder text — files with no YAML front matter render
|
||||
// as a genuinely empty pane. Showing a synthetic example would
|
||||
// make the file look like it had data when it doesn't.
|
||||
// Placeholder: in server mode, hint the recognised front-matter keys
|
||||
// (doctype, numbering, …) as greyed text so authors can discover them.
|
||||
// It's placeholder-only — inserts nothing, vanishes on the first
|
||||
// keystroke — so arbitrary keys stay free and a file with no front
|
||||
// matter still renders as a genuinely empty pane. The text is fetched
|
||||
// from the server (/.api/frontmatter), the single source of truth, so
|
||||
// it never drifts from what the converter honours. file:// mode shows
|
||||
// no placeholder (conversion is server-only).
|
||||
fmTextarea.placeholder = '';
|
||||
applyFrontMatterPlaceholder(fmTextarea);
|
||||
fmBody.appendChild(fmTextarea);
|
||||
// Non-blocking warning shown when front matter disagrees with the
|
||||
// canonical filename on an identity field (tracking_number / revision /
|
||||
// status / title). The filename always wins in the rendered doc; this
|
||||
// just tells the author their front-matter value is being ignored.
|
||||
var fmWarn = document.createElement('div');
|
||||
fmWarn.className = 'md-fm__warn';
|
||||
fmWarn.hidden = true;
|
||||
fmWarn.style.cssText = 'color:#92400e;background:#fffbeb;border:1px solid '
|
||||
+ '#fcd34d;border-radius:4px;padding:4px 8px;margin:0 0 4px;font-size:'
|
||||
+ '0.78rem;line-height:1.4;';
|
||||
fmSection.appendChild(fmHeader);
|
||||
fmSection.appendChild(fmWarn);
|
||||
fmSection.appendChild(fmBody);
|
||||
sidebar.appendChild(fmSection);
|
||||
|
||||
|
|
@ -437,7 +489,7 @@
|
|||
var sourceEl = document.createElement('span');
|
||||
sourceEl.className = 'md-shell__source';
|
||||
if (isZipMemberNode(node)) {
|
||||
sourceEl.textContent = 'read-only (zip)';
|
||||
sourceEl.textContent = isEditableZipMember(node) ? 'config bundle' : 'read-only (zip)';
|
||||
} else if (node.handle) {
|
||||
sourceEl.textContent = 'local';
|
||||
} else if (node.url) {
|
||||
|
|
@ -465,11 +517,18 @@
|
|||
// and routes through ServeConverted. Cleaner than the
|
||||
// old `?convert=` query form — right-clicking the link
|
||||
// gives a sensible "Save as <file>.docx" prompt.
|
||||
var mdUrlBase = node.url.replace(/\.md$/i, '');
|
||||
['docx', 'html', 'pdf'].forEach(function (fmt) {
|
||||
//
|
||||
// Format set + URL come from the download module's canonical
|
||||
// conversion matrix (download.exportTargets / convertUrl) — the
|
||||
// SAME source of truth the Export context-menu uses, so the
|
||||
// editor's buttons and the menu never offer different formats.
|
||||
var dl = window.app.modules.download;
|
||||
var mdTargets = (dl && dl.exportTargets) ? dl.exportTargets('md') : ['docx', 'html', 'pdf'];
|
||||
mdTargets.forEach(function (fmt) {
|
||||
var a = document.createElement('a');
|
||||
a.className = 'btn btn-sm btn-secondary md-shell__download';
|
||||
a.href = mdUrlBase + '.' + fmt;
|
||||
a.href = (dl && dl.convertUrl) ? dl.convertUrl(node.url, fmt)
|
||||
: node.url.replace(/\.md$/i, '') + '.' + fmt;
|
||||
// target=_blank: clicks open in a new tab. The server
|
||||
// sends Content-Disposition: inline, so the new tab
|
||||
// either renders (HTML → web page; PDF → browser's
|
||||
|
|
@ -691,14 +750,49 @@
|
|||
}, 250);
|
||||
editor.on('change', onChange);
|
||||
|
||||
// Identity fields are sourced from the canonical ZDDC filename; setting
|
||||
// a different value in front matter is ignored at render (the filename
|
||||
// wins). Surface a mismatch so the author isn't silently overridden.
|
||||
// Maps the front-matter key to the parseFilename field.
|
||||
var IDENTITY_FIELDS = [
|
||||
{ fm: 'title', fn: 'title', label: 'title' },
|
||||
{ fm: 'tracking_number', fn: 'trackingNumber', label: 'tracking number' },
|
||||
{ fm: 'revision', fn: 'revision', label: 'revision' },
|
||||
{ fm: 'status', fn: 'status', label: 'status' }
|
||||
];
|
||||
function checkFilenameMismatch() {
|
||||
var z = window.zddc;
|
||||
var fn = (z && z.parseFilename) ? z.parseFilename(node.name) : null;
|
||||
// Only meaningful for a conventional ZDDC filename (it always has a
|
||||
// tracking number). Non-conventional files have no canonical
|
||||
// identity, so front matter is free — no warning.
|
||||
if (!fn || !fn.trackingNumber) { fmWarn.hidden = true; return; }
|
||||
var data = parseFrontMatter('---\n' + fmTextarea.value + '\n---\n').data || {};
|
||||
var clashes = [];
|
||||
IDENTITY_FIELDS.forEach(function (f) {
|
||||
if (!(f.fm in data)) return;
|
||||
var got = String(data[f.fm] == null ? '' : data[f.fm]).trim();
|
||||
var want = String(fn[f.fn] == null ? '' : fn[f.fn]).trim();
|
||||
if (got !== '' && want !== '' && got !== want) {
|
||||
clashes.push(f.label + ' “' + got + '” ≠ filename “' + want + '”');
|
||||
}
|
||||
});
|
||||
if (!clashes.length) { fmWarn.hidden = true; fmWarn.textContent = ''; return; }
|
||||
fmWarn.textContent = '⚠ Front matter disagrees with the filename (the '
|
||||
+ 'filename wins): ' + clashes.join('; ') + '.';
|
||||
fmWarn.hidden = false;
|
||||
}
|
||||
|
||||
var onFmChange = debounce(async function () {
|
||||
if (currentInstance !== instance) return;
|
||||
var body = editor.getMarkdown();
|
||||
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
||||
if (currentInstance !== instance) return;
|
||||
markDirty(h !== instance.hash);
|
||||
checkFilenameMismatch();
|
||||
}, 250);
|
||||
fmTextarea.addEventListener('input', onFmChange);
|
||||
checkFilenameMismatch(); // initial state on load
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────
|
||||
// Mark a successful write: adopt the new server ETag (so the next
|
||||
|
|
|
|||
|
|
@ -53,9 +53,13 @@
|
|||
}
|
||||
|
||||
var isZipMemberNode = util.isZipMemberNode;
|
||||
var isEditableZipMember = util.isEditableZipMember;
|
||||
|
||||
function canSave(node) {
|
||||
if (isZipMemberNode(node)) return false;
|
||||
// A .zddc.zip bundle member is saveable iff editable (elevated admin);
|
||||
// the server's ServeZipWrite is the real gate. Other zip members are
|
||||
// read-only.
|
||||
if (isZipMemberNode(node)) return isEditableZipMember(node);
|
||||
// Virtual .zddc placeholders are designed to be saved — a PUT
|
||||
// materializes the file from the synthetic body and the next
|
||||
// listing serves a real entry. Every other virtual node (per-
|
||||
|
|
@ -110,7 +114,18 @@
|
|||
views: 'viewmap',
|
||||
convert: 'convert',
|
||||
created_by: 'string',
|
||||
inherit: 'bool'
|
||||
inherit: 'bool',
|
||||
// Keys the Go decoder (zddc/internal/zddc/file.go) accepts that the
|
||||
// lint was missing — flagged valid configs as "unknown key".
|
||||
party_source: 'string',
|
||||
history: 'bool',
|
||||
history_globs: 'string[]',
|
||||
records: 'object',
|
||||
auto_own_roles: 'string[]',
|
||||
received_path: 'string',
|
||||
planned_response_date: 'string',
|
||||
planned_review_date: 'string',
|
||||
field_codes: 'object'
|
||||
};
|
||||
|
||||
var ACL_KEYS = { inherit: 'bool', permissions: 'stringmap',
|
||||
|
|
@ -275,6 +290,12 @@
|
|||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||
walkObject(val, CONVERT_KEYS, path, issues);
|
||||
return;
|
||||
case 'object':
|
||||
// Free-form map (records, field_codes) — the server accepts any
|
||||
// nested shape, so we only check it's a mapping, not its keys.
|
||||
if (t === 'null') return;
|
||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -429,9 +450,21 @@
|
|||
var schemaTag = document.createElement('span');
|
||||
schemaTag.className = 'md-shell__source yaml-shell__schema';
|
||||
if (isZddcFile(node.name)) {
|
||||
schemaTag.textContent = '.zddc schema';
|
||||
schemaTag.textContent = '.zddc schema ↗';
|
||||
schemaTag.title = 'Linted against the .zddc cascade schema '
|
||||
+ '(unknown keys, bad enums, and wrong types are flagged).';
|
||||
+ '(unknown keys, bad enums, and wrong types are flagged). '
|
||||
+ 'Click to view the full JSON Schema.';
|
||||
// Clickable → opens the canonical machine grammar the lint mirrors.
|
||||
schemaTag.classList.add('yaml-shell__schema--link');
|
||||
schemaTag.setAttribute('role', 'link');
|
||||
schemaTag.setAttribute('tabindex', '0');
|
||||
var openSchema = function () {
|
||||
window.open('/.api/zddc-schema', '_blank', 'noopener');
|
||||
};
|
||||
schemaTag.addEventListener('click', openSchema);
|
||||
schemaTag.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); }
|
||||
});
|
||||
} else {
|
||||
schemaTag.textContent = 'YAML';
|
||||
}
|
||||
|
|
@ -444,7 +477,7 @@
|
|||
|
||||
var sourceEl = document.createElement('span');
|
||||
sourceEl.className = 'md-shell__source';
|
||||
if (isZipMemberNode(node)) sourceEl.textContent = 'read-only (zip)';
|
||||
if (isZipMemberNode(node)) sourceEl.textContent = isEditableZipMember(node) ? 'config bundle' : 'read-only (zip)';
|
||||
else if (node.handle) sourceEl.textContent = 'local';
|
||||
else if (node.url) sourceEl.textContent = 'server';
|
||||
|
||||
|
|
|
|||
325
browse/js/preview-zddc-form.js
Normal file
325
browse/js/preview-zddc-form.js
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
// preview-zddc-form.js — schema-driven FORM view for .zddc files.
|
||||
//
|
||||
// The user shouldn't have to understand YAML cascades to configure a project.
|
||||
// This renders the .zddc as a form: the OPTION fields (the blanks an operator
|
||||
// fills — title, admins, role members) are editable widgets; the STRUCTURE
|
||||
// (paths, WORM, tools, behaviours — what a ZDDC project IS) is shown read-only
|
||||
// for context. The split is driven by the server's .zddc JSON Schema
|
||||
// (/.api/zddc-schema, x-zddc-tier: structure|option). Saving merges the edited
|
||||
// option values back into the file (preserving all structure keys) and PUTs the
|
||||
// YAML — which works for an on-disk .zddc and for a .zddc.zip bundle member
|
||||
// (the server's ServeZipWrite). An "Edit raw YAML" escape hands off to the
|
||||
// CodeMirror editor for anything the form doesn't cover (field_codes, display,
|
||||
// convert, advanced acl).
|
||||
//
|
||||
// This is the primary .zddc editor; the raw-YAML plugin (preview-yaml.js) is
|
||||
// the power-user fallback.
|
||||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
var util = app.modules.util || window.app.modules.util;
|
||||
var escapeHtml = util.escapeHtml;
|
||||
var saveFile = util.saveFile;
|
||||
var isEditableZipMember = util.isEditableZipMember;
|
||||
|
||||
var current = null; // { node, dirty, etag, lastModified }
|
||||
|
||||
// Cached .zddc schema (property → {tier, description}).
|
||||
var schemaProps = null;
|
||||
function loadSchema() {
|
||||
if (schemaProps) return Promise.resolve(schemaProps);
|
||||
return fetch('/.api/zddc-schema', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) { schemaProps = (j && j.properties) || {}; return schemaProps; })
|
||||
.catch(function () { schemaProps = {}; return schemaProps; });
|
||||
}
|
||||
|
||||
function handles(node) {
|
||||
return !!node && (node.name === '.zddc' || /\.zddc$/i.test(node.name || ''));
|
||||
}
|
||||
|
||||
function canSave(node) {
|
||||
if (isEditableZipMember(node)) return true;
|
||||
if (node.url && window.app.state.source === 'server' && window.zddc.cap) {
|
||||
// A .zddc edit is an ActionAdmin write — needs the 'a' verb.
|
||||
return window.zddc.cap.has(node, 'a');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isDirty() { return !!(current && current.dirty); }
|
||||
function currentNode() { return current ? current.node : null; }
|
||||
function dispose() { current = null; }
|
||||
|
||||
function desc(name) {
|
||||
return (schemaProps && schemaProps[name] && schemaProps[name].description) || '';
|
||||
}
|
||||
|
||||
// ── small DOM helpers ───────────────────────────────────────────────────
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
// A growable list of single-string rows (used for admins + role members).
|
||||
function listEditor(values, placeholder, onChange, readOnly) {
|
||||
var wrap = el('div', 'zf-list');
|
||||
function addRow(val) {
|
||||
var row = el('div', 'zf-list__row');
|
||||
row.style.cssText = 'display:flex;gap:.4rem;margin:.2rem 0;';
|
||||
var input = el('input');
|
||||
input.type = 'text';
|
||||
input.value = val || '';
|
||||
input.placeholder = placeholder || '';
|
||||
input.style.cssText = 'flex:1;padding:.3rem;font-family:var(--code,monospace);';
|
||||
input.disabled = !!readOnly;
|
||||
input.addEventListener('input', onChange);
|
||||
row.appendChild(input);
|
||||
if (!readOnly) {
|
||||
var del = el('button', null, '−');
|
||||
del.type = 'button';
|
||||
del.title = 'Remove';
|
||||
del.addEventListener('click', function () { row.remove(); onChange(); });
|
||||
row.appendChild(del);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
(values || []).forEach(addRow);
|
||||
if (!readOnly) {
|
||||
var add = el('button', 'zf-add', '+ add');
|
||||
add.type = 'button';
|
||||
add.style.cssText = 'margin-top:.2rem;';
|
||||
add.addEventListener('click', function () { addRow(''); onChange(); });
|
||||
wrap.appendChild(add);
|
||||
}
|
||||
wrap._values = function () {
|
||||
return Array.prototype.slice.call(wrap.querySelectorAll('.zf-list__row input'))
|
||||
.map(function (i) { return i.value.trim(); })
|
||||
.filter(function (v) { return v; });
|
||||
};
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function render(node, container, ctx) {
|
||||
dispose();
|
||||
var text, etag = null, lastModified = null;
|
||||
try {
|
||||
if (ctx.getContentWithVersion) {
|
||||
var loaded = await ctx.getContentWithVersion(node);
|
||||
text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
|
||||
etag = loaded.etag;
|
||||
lastModified = loaded.lastModified;
|
||||
} else {
|
||||
text = new TextDecoder('utf-8', { fatal: false }).decode(await ctx.getArrayBuffer(node));
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="preview-empty" style="color:var(--danger)">'
|
||||
+ 'Could not read ' + escapeHtml(node.name) + ': ' + escapeHtml(e.message || String(e)) + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var data = {};
|
||||
try { data = (window.jsyaml && window.jsyaml.load(text)) || {}; } catch (_) { data = {}; }
|
||||
if (typeof data !== 'object' || Array.isArray(data)) data = {};
|
||||
await loadSchema();
|
||||
|
||||
var editable = canSave(node);
|
||||
current = { node: node, dirty: false, etag: etag, lastModified: lastModified };
|
||||
|
||||
container.innerHTML = '';
|
||||
var shell = el('div', 'yaml-shell zddc-form');
|
||||
shell.style.cssText = 'padding:.75rem 1rem;overflow:auto;height:100%;box-sizing:border-box;';
|
||||
container.appendChild(shell);
|
||||
|
||||
// Header.
|
||||
var hdr = el('div', 'md-shell__infohdr');
|
||||
hdr.appendChild(el('span', 'md-shell__title', node.name));
|
||||
var srcTag = el('span', 'md-shell__source', isEditableZipMember(node) ? 'config bundle' : (editable ? '.zddc form' : 'read-only'));
|
||||
hdr.appendChild(srcTag);
|
||||
var dirtyEl = el('span', 'md-shell__dirty');
|
||||
hdr.appendChild(dirtyEl);
|
||||
var statusEl = el('span', 'md-shell__status');
|
||||
hdr.appendChild(statusEl);
|
||||
var rawBtn = el('button', 'btn btn-sm btn-secondary', 'Edit raw YAML');
|
||||
rawBtn.type = 'button';
|
||||
rawBtn.title = 'Switch to the raw YAML editor (covers every key).';
|
||||
rawBtn.addEventListener('click', function () {
|
||||
var ym = window.app.modules.yamledit;
|
||||
if (ym && ym.render) { dispose(); ym.render(node, container, ctx); }
|
||||
});
|
||||
hdr.appendChild(rawBtn);
|
||||
var saveBtn = el('button', 'btn btn-sm btn-primary', 'Save');
|
||||
saveBtn.type = 'button';
|
||||
saveBtn.disabled = true;
|
||||
hdr.appendChild(saveBtn);
|
||||
shell.appendChild(hdr);
|
||||
|
||||
function markDirty() {
|
||||
if (!current) return;
|
||||
current.dirty = true;
|
||||
dirtyEl.textContent = '● modified';
|
||||
if (editable) saveBtn.disabled = false;
|
||||
}
|
||||
|
||||
var help = el('p', 'help');
|
||||
help.style.cssText = 'color:var(--color-text-muted,#666);font-size:.85rem;margin:.3rem 0 .5rem;';
|
||||
help.textContent = editable
|
||||
? 'Project options. Structural keys are read-only — use Edit raw YAML.'
|
||||
: 'Read-only — you need admin authority over this path to edit it.';
|
||||
shell.appendChild(help);
|
||||
|
||||
// ── OPTION fields ───────────────────────────────────────────────────
|
||||
function section(title, hint, tight) {
|
||||
var s = el('section', 'zf-section');
|
||||
s.style.cssText = 'margin:0 0 1rem;';
|
||||
var h = el('h3', null, title);
|
||||
// `tight` drops the heading's top margin for the FIRST section so
|
||||
// it doesn't stack with the intro's bottom margin (the gap above
|
||||
// Title was reading as excessive). Later sections keep the gap.
|
||||
h.style.cssText = 'font-size:1em;margin:' + (tight ? '0' : '.6rem') + ' 0 .2rem;';
|
||||
s.appendChild(h);
|
||||
if (hint) {
|
||||
var p = el('p', 'help', hint);
|
||||
p.style.cssText = 'color:var(--color-text-muted,#888);font-size:.8rem;margin:0 0 .3rem;';
|
||||
s.appendChild(p);
|
||||
}
|
||||
shell.appendChild(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
// title
|
||||
var titleSec = section('Title', desc('title'), true);
|
||||
var titleInput = el('input');
|
||||
titleInput.type = 'text';
|
||||
titleInput.value = (typeof data.title === 'string') ? data.title : '';
|
||||
titleInput.disabled = !editable;
|
||||
titleInput.style.cssText = 'width:100%;max-width:32rem;padding:.35rem;';
|
||||
titleInput.addEventListener('input', markDirty);
|
||||
titleSec.appendChild(titleInput);
|
||||
|
||||
// admins
|
||||
var adminsSec = section('Admins', desc('admins'));
|
||||
var adminsList = listEditor(Array.isArray(data.admins) ? data.admins : [], 'email or *@domain', markDirty, !editable);
|
||||
adminsSec.appendChild(adminsList);
|
||||
|
||||
// roles (map name → {members:[]})
|
||||
var rolesSec = section('Roles', desc('roles') || 'Who belongs to each project role.');
|
||||
var rolesHost = el('div', 'zf-roles');
|
||||
rolesSec.appendChild(rolesHost);
|
||||
var roleEditors = []; // {name, membersEl, getName}
|
||||
function addRole(name, members) {
|
||||
var box = el('div', 'zf-role');
|
||||
box.style.cssText = 'border:1px solid rgba(0,0,0,0.1);border-radius:4px;padding:.4rem .6rem;margin:.3rem 0;';
|
||||
var nameRow = el('div');
|
||||
nameRow.style.cssText = 'display:flex;gap:.4rem;align-items:center;margin-bottom:.2rem;';
|
||||
var nameInput = el('input');
|
||||
nameInput.type = 'text';
|
||||
nameInput.value = name || '';
|
||||
nameInput.placeholder = 'role name (e.g. document_controller)';
|
||||
nameInput.style.cssText = 'font-family:var(--code,monospace);font-weight:600;flex:1;padding:.25rem;';
|
||||
nameInput.disabled = !editable;
|
||||
nameInput.addEventListener('input', markDirty);
|
||||
nameRow.appendChild(el('span', null, '👥'));
|
||||
nameRow.appendChild(nameInput);
|
||||
box.appendChild(nameRow);
|
||||
var membersList = listEditor(members || [], 'member email or *@domain', markDirty, !editable);
|
||||
box.appendChild(membersList);
|
||||
rolesHost.appendChild(box);
|
||||
roleEditors.push({ getName: function () { return nameInput.value.trim(); }, members: membersList });
|
||||
}
|
||||
var roles = (data.roles && typeof data.roles === 'object') ? data.roles : {};
|
||||
Object.keys(roles).forEach(function (rn) {
|
||||
var m = (roles[rn] && Array.isArray(roles[rn].members)) ? roles[rn].members : [];
|
||||
addRole(rn, m);
|
||||
});
|
||||
if (editable) {
|
||||
var addRoleBtn = el('button', 'zf-add', '+ add role');
|
||||
addRoleBtn.type = 'button';
|
||||
addRoleBtn.addEventListener('click', function () { addRole('', []); markDirty(); });
|
||||
rolesSec.appendChild(addRoleBtn);
|
||||
}
|
||||
|
||||
// ── STRUCTURE (read-only) ───────────────────────────────────────────
|
||||
var structKeys = Object.keys(data).filter(function (k) {
|
||||
return schemaProps[k] && schemaProps[k].tier === 'structure';
|
||||
});
|
||||
// Also surface option keys this form doesn't render yet, as read-only.
|
||||
var rawHandled = { title: 1, admins: 1, roles: 1 };
|
||||
var otherKeys = Object.keys(data).filter(function (k) {
|
||||
return !rawHandled[k] && !(schemaProps[k] && schemaProps[k].tier === 'structure');
|
||||
});
|
||||
if (structKeys.length || otherKeys.length) {
|
||||
var det = el('details', 'zf-structure');
|
||||
det.style.cssText = 'margin-top:.5rem;';
|
||||
var sum = el('summary', null, 'Structure & advanced (read-only — edit via raw YAML)');
|
||||
sum.style.cssText = 'cursor:pointer;color:var(--color-text-muted,#666);font-size:.85rem;';
|
||||
det.appendChild(sum);
|
||||
var subset = {};
|
||||
structKeys.concat(otherKeys).forEach(function (k) { subset[k] = data[k]; });
|
||||
var pre = el('pre');
|
||||
pre.style.cssText = 'background:var(--code-bg,#f6f8fa);padding:.5rem;border-radius:4px;overflow:auto;font-size:.8rem;';
|
||||
try { pre.textContent = window.jsyaml ? window.jsyaml.dump(subset) : JSON.stringify(subset, null, 2); }
|
||||
catch (_) { pre.textContent = JSON.stringify(subset, null, 2); }
|
||||
det.appendChild(pre);
|
||||
shell.appendChild(det);
|
||||
}
|
||||
|
||||
// ── Save ────────────────────────────────────────────────────────────
|
||||
function buildContent() {
|
||||
var out = {};
|
||||
// Preserve everything not managed by the form (structure + unrendered options).
|
||||
Object.keys(data).forEach(function (k) { if (!rawHandled[k]) out[k] = data[k]; });
|
||||
var t = titleInput.value.trim();
|
||||
if (t) out.title = t;
|
||||
var admins = adminsList._values();
|
||||
if (admins.length) out.admins = admins;
|
||||
var rolesOut = {};
|
||||
roleEditors.forEach(function (re) {
|
||||
var n = re.getName();
|
||||
if (!n) return;
|
||||
var mem = re.members._values();
|
||||
rolesOut[n] = mem.length ? { members: mem } : { members: [] };
|
||||
});
|
||||
if (Object.keys(rolesOut).length) out.roles = rolesOut;
|
||||
return window.jsyaml.dump(out);
|
||||
}
|
||||
|
||||
saveBtn.addEventListener('click', async function () {
|
||||
if (!current || !editable) return;
|
||||
saveBtn.disabled = true;
|
||||
statusEl.textContent = 'Saving…';
|
||||
var content;
|
||||
try { content = buildContent(); }
|
||||
catch (e) { statusEl.textContent = 'Serialize failed: ' + (e.message || e); return; }
|
||||
try {
|
||||
var res = await saveFile(node, content, 'application/yaml; charset=utf-8',
|
||||
{ etag: current.etag, lastModified: current.lastModified });
|
||||
if (!current) return;
|
||||
current.etag = (res && res.etag) || current.etag;
|
||||
current.dirty = false;
|
||||
dirtyEl.textContent = '';
|
||||
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
||||
if (window.zddc && window.zddc.toast) window.zddc.toast('Saved ' + node.name, 'success');
|
||||
} catch (e) {
|
||||
if (e && e.status === 412 && window.app.modules.conflict) {
|
||||
window.app.modules.conflict.open({
|
||||
name: node.name, theirsText: '', minePut: function () { return saveFile(node, content, 'application/yaml; charset=utf-8', {}); }
|
||||
});
|
||||
statusEl.textContent = 'Conflict — changed on server.';
|
||||
} else {
|
||||
statusEl.textContent = 'Save failed: ' + (e && e.message ? e.message : e);
|
||||
}
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.modules.zddcform = {
|
||||
handles: handles,
|
||||
render: render,
|
||||
isDirty: isDirty,
|
||||
currentNode: currentNode,
|
||||
dispose: dispose
|
||||
};
|
||||
})(window.app);
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
|
||||
function editorModules() {
|
||||
var m = window.app.modules;
|
||||
return [m.markdown, m.yamledit].filter(Boolean);
|
||||
return [m.markdown, m.yamledit, m.zddcform].filter(Boolean);
|
||||
}
|
||||
|
||||
function disposeEditors() {
|
||||
|
|
@ -211,6 +211,19 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// .zddc form view: a schema-driven form (option fields editable,
|
||||
// structure read-only) is the PRIMARY editor for .zddc files. It hands
|
||||
// off to the raw YAML editor on demand. Other YAML files skip it.
|
||||
var zddcForm = window.app.modules.zddcform;
|
||||
if (zddcForm && zddcForm.handles(node)) {
|
||||
try {
|
||||
await zddcForm.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
|
||||
} catch (e) {
|
||||
renderError(container, '.zddc form render failed: ' + (e.message || e));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// YAML plugin: .yaml / .yml / .zddc / *.zddc.yaml route to a
|
||||
// CodeMirror 5 editor with js-yaml linting; .zddc files also
|
||||
// get a schema-aware lint pass.
|
||||
|
|
@ -456,12 +469,47 @@
|
|||
// ── Public entry ────────────────────────────────────────────────────────
|
||||
|
||||
async function showFilePreview(node, opts) {
|
||||
if (node.isDir) return;
|
||||
opts = opts || {};
|
||||
// Table-leaf dirs (mdl/rsk/ssr, default_tool=tables) open the tables
|
||||
// tool inline in the preview pane instead of expanding/navigating.
|
||||
if (window.app.modules.util.isTableLeaf(node)) return renderTableLeaf(node);
|
||||
if (node.isDir) return;
|
||||
if (opts.popup) return renderInPopup(node);
|
||||
return renderInline(node, opts);
|
||||
}
|
||||
|
||||
// renderTableLeaf embeds the tables tool for a default_tool=tables
|
||||
// directory as an iframe scoped to that dir — the same in-pane tool
|
||||
// embed pattern grid.js uses for classifier. Server mode only (the
|
||||
// default_tool listing hint that flags a table-leaf is absent offline,
|
||||
// so this never fires on file:// — the dir stays an ordinary folder).
|
||||
function renderTableLeaf(node) {
|
||||
disposeEditors();
|
||||
var container = document.getElementById('previewBody');
|
||||
var titleEl = document.getElementById('previewTitle');
|
||||
var metaEl = document.getElementById('previewMeta');
|
||||
var popoutBtn = document.getElementById('previewPopout');
|
||||
if (!container) return;
|
||||
if (titleEl) titleEl.textContent = node.displayName || node.name;
|
||||
if (metaEl) metaEl.textContent = 'table';
|
||||
if (popoutBtn) popoutBtn.classList.add('hidden');
|
||||
if (window.app.state.source !== 'server' || !node.url) {
|
||||
renderEmpty(container, 'Table view is available in server mode.');
|
||||
return;
|
||||
}
|
||||
// The tables tool is served at the dir's NO-SLASH URL (the cascade's
|
||||
// default_tool routing). The trailing-slash form would serve the
|
||||
// browse listing instead, and <dir>/tables.html 404s for a virtual
|
||||
// dir (mdl/rsk/ssr have no on-disk folder). So strip the slash.
|
||||
var src = node.url.replace(/\/+$/, '');
|
||||
container.innerHTML = '';
|
||||
var frame = document.createElement('iframe');
|
||||
frame.className = 'preview-iframe';
|
||||
frame.src = src;
|
||||
frame.setAttribute('title', 'Table: ' + (node.displayName || node.name));
|
||||
container.appendChild(frame);
|
||||
}
|
||||
|
||||
window.app.modules.preview = {
|
||||
showFilePreview: showFilePreview,
|
||||
// Tear down any live editor + blank the pane (rescope / popstate).
|
||||
|
|
|
|||
|
|
@ -58,7 +58,12 @@
|
|||
// undefined — Caddy / FS-API listings (no verbs field).
|
||||
// Per-entry gates skip the cascade check
|
||||
// and fall back to canMutate / writable.
|
||||
verbs: typeof raw.verbs === 'string' ? raw.verbs : undefined
|
||||
verbs: typeof raw.verbs === 'string' ? raw.verbs : undefined,
|
||||
// Cascade default tool for a directory entry. When "tables"
|
||||
// (mdl/rsk/ssr), the node is a TABLE LEAF: rendered without a
|
||||
// chevron and, on click, opens the tables tool in the preview
|
||||
// pane instead of expanding/navigating. See isTableLeaf().
|
||||
defaultTool: raw.defaultTool || ''
|
||||
};
|
||||
state.nodes.set(id, node);
|
||||
return node;
|
||||
|
|
@ -275,6 +280,8 @@
|
|||
};
|
||||
|
||||
function symbolForNode(node) {
|
||||
// Table-leaf dirs (mdl/rsk/ssr) read as a table, not a folder.
|
||||
if (window.app.modules.util.isTableLeaf(node)) return 'icon-file-spreadsheet';
|
||||
if (node.isDir) return 'icon-folder';
|
||||
if (node.isZip) return 'icon-folder-archive';
|
||||
// `.zddc` (no extension) is the cascade config — same family
|
||||
|
|
@ -351,7 +358,10 @@
|
|||
// via the events.js click handler (it sees the modifier key).
|
||||
function rowHtml(node) {
|
||||
var indent = 0.4 + node.depth * 1.0;
|
||||
var expandable = node.isDir || node.isZip;
|
||||
// Table-leaf dirs render like a file: no chevron, click opens the
|
||||
// table in the preview pane (handled by events.js / preview.js).
|
||||
var tableLeaf = window.app.modules.util.isTableLeaf(node);
|
||||
var expandable = (node.isDir || node.isZip) && !tableLeaf;
|
||||
var iconChar = iconForNode(node);
|
||||
var chevronClass = 'tree-name__chevron'
|
||||
+ (expandable ? '' : ' tree-name__chevron--leaf');
|
||||
|
|
@ -385,6 +395,7 @@
|
|||
+ '" data-id="' + node.id
|
||||
+ '" data-isdir="' + node.isDir
|
||||
+ '" data-iszip="' + node.isZip + '"'
|
||||
+ (tableLeaf ? ' data-tableleaf="true"' : '')
|
||||
+ (node.virtual ? ' data-virtual="true"' : '')
|
||||
+ ' style="padding-left:' + indent + 'rem"'
|
||||
+ ' role="treeitem" tabindex="-1">'
|
||||
|
|
|
|||
|
|
@ -90,6 +90,19 @@
|
|||
return false;
|
||||
}
|
||||
|
||||
// isEditableZipMember reports whether node is a member of the .zddc.zip
|
||||
// config bundle — the one case where the server accepts a write into a zip
|
||||
// (ServeZipWrite). The server gates BOTH browsing and writing the bundle on
|
||||
// standing config-edit authority (a subtree admin / `a`-verb holder, no
|
||||
// elevation), so if this member is even visible the session can edit it —
|
||||
// no elevation check needed here. Every other zip member (content archives,
|
||||
// WORM records) stays read-only. The server is the real gate; this drives
|
||||
// editor UX.
|
||||
function isEditableZipMember(node) {
|
||||
if (!node || !node.url || window.app.state.source !== 'server') return false;
|
||||
return /\.zddc\.zip\//i.test(node.url);
|
||||
}
|
||||
|
||||
// Thrown by saveFile when the server rejects a write with 412
|
||||
// Precondition Failed — the file changed under us since we loaded it.
|
||||
// Callers branch on `.status === 412` to open the conflict UI instead
|
||||
|
|
@ -184,6 +197,17 @@
|
|||
return name;
|
||||
}
|
||||
|
||||
// isTableLeaf reports whether a directory node should behave as a
|
||||
// click-to-table LEAF rather than an expandable folder — i.e. the
|
||||
// cascade resolved its default tool to "tables" (mdl/rsk/ssr and any
|
||||
// operator-configured table dir). The tree renders it without a
|
||||
// chevron and the preview pane opens the tables tool for it. Server
|
||||
// mode only: defaultTool is a server-computed listing hint, absent
|
||||
// offline (file:// folders stay ordinary expandable dirs).
|
||||
function isTableLeaf(node) {
|
||||
return !!(node && node.isDir && node.defaultTool === 'tables');
|
||||
}
|
||||
|
||||
window.app.modules.util = {
|
||||
escapeHtml: escapeHtml,
|
||||
hashContent: hashContent,
|
||||
|
|
@ -193,6 +217,8 @@
|
|||
fetchAccessEmails: fetchAccessEmails,
|
||||
fmtSize: fmtSize,
|
||||
isZipMemberNode: isZipMemberNode,
|
||||
isEditableZipMember: isEditableZipMember,
|
||||
isTableLeaf: isTableLeaf,
|
||||
saveFile: saveFile,
|
||||
saveCopy: saveCopy,
|
||||
ConflictError: ConflictError
|
||||
|
|
|
|||
|
|
@ -28,12 +28,6 @@
|
|||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||
</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>
|
||||
|
|
@ -66,18 +60,10 @@
|
|||
<div id="browseView" class="browse-view">
|
||||
<div class="pane tree-pane" id="treePane">
|
||||
<div class="tree-pane__toolbar">
|
||||
<input type="search"
|
||||
id="treeFilter"
|
||||
class="tree-filter"
|
||||
placeholder="Filter files…"
|
||||
aria-label="Filter the tree by name, tracking number, status, revision, or title"
|
||||
autocomplete="off"
|
||||
spellcheck="false">
|
||||
<!-- Sort + Hidden sit above the autofilter box. Create
|
||||
actions (New folder / New file) live in the
|
||||
right-click context menu, not here. -->
|
||||
<div class="tree-pane__controls">
|
||||
<button type="button" id="newFolderBtn" class="btn btn-sm btn--subtle"
|
||||
title="New folder in the current directory">New folder</button>
|
||||
<button type="button" id="newFileBtn" class="btn btn-sm btn--subtle"
|
||||
title="New markdown file in the current directory">New file</button>
|
||||
<label class="tp-control" title="Sort order">
|
||||
<span class="tp-control__label">Sort</span>
|
||||
<select id="sortSelect" aria-label="Sort order">
|
||||
|
|
@ -92,6 +78,13 @@
|
|||
<span class="tp-control__label">Hidden</span>
|
||||
</label>
|
||||
</div>
|
||||
<input type="search"
|
||||
id="treeFilter"
|
||||
class="tree-filter"
|
||||
placeholder="Filter files…"
|
||||
aria-label="Filter the tree by name, tracking number, status, revision, or title"
|
||||
autocomplete="off"
|
||||
spellcheck="false">
|
||||
</div>
|
||||
<div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ concat_files \
|
|||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/base.css" \
|
||||
"css/layout.css" \
|
||||
|
|
@ -62,6 +63,7 @@ concat_files \
|
|||
"js/excel.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/profile-menu.js" \
|
||||
"../shared/cap.js" \
|
||||
> "$js_raw"
|
||||
|
||||
|
|
|
|||
|
|
@ -32,12 +32,6 @@
|
|||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||
</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">?</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ concat_files \
|
|||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/form.css" \
|
||||
> "$css_temp"
|
||||
|
|
@ -32,6 +33,7 @@ concat_files \
|
|||
"../shared/logo.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/profile-menu.js" \
|
||||
"../shared/cap.js" \
|
||||
"js/app.js" \
|
||||
"js/context.js" \
|
||||
|
|
|
|||
|
|
@ -26,12 +26,6 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ concat_files \
|
|||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/landing.css" \
|
||||
> "$css_temp"
|
||||
|
|
@ -34,6 +35,7 @@ concat_files \
|
|||
"../shared/logo.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/profile-menu.js" \
|
||||
"../shared/cap.js" \
|
||||
"js/landing.js" \
|
||||
> "$js_raw"
|
||||
|
|
|
|||
|
|
@ -26,12 +26,6 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,20 @@
|
|||
/* Shape */
|
||||
--radius: 4px;
|
||||
|
||||
/* Spacing scale — referenced by the tables tool (tables/css/table.css).
|
||||
Were undefined (var() with no fallback → collapsed to 0), which left
|
||||
table cells unpadded and the table flush to the viewport edges. */
|
||||
--spacing-sm: 0.4rem;
|
||||
--spacing-md: 0.8rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
|
||||
/* Token aliases the tables tool references under --color-*/--radius-*
|
||||
names; map them to the canonical tokens (themed values flow through). */
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-border: var(--border);
|
||||
--color-bg-elevated: var(--bg-secondary);
|
||||
--radius-sm: var(--radius);
|
||||
|
||||
/* Typography. --font-display covers headings (Source Serif 4 — a refined
|
||||
transitional serif that reads as "engineering / document / serious"
|
||||
without being academic). --font is body UI text (IBM Plex Sans —
|
||||
|
|
|
|||
|
|
@ -1,50 +1,7 @@
|
|||
/* 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/elevation.css — page-wide armed chrome for admin mode.
|
||||
The elevate CONTROL is the "Admin mode" item in the shared profile menu
|
||||
(shared/profile-menu.{js,css}); this file only styles the unmistakable
|
||||
"you are elevated" cues: the red viewport frame + the sticky banner. */
|
||||
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
easy to miss; these add an inescapable visual cue:
|
||||
|
|
|
|||
|
|
@ -1,23 +1,28 @@
|
|||
// shared/elevation.js — admin elevation via URL toggle.
|
||||
// shared/elevation.js — admin elevation state machine.
|
||||
//
|
||||
// Sudo-style model: admins behave as normal users by default; elevating
|
||||
// the session turns on admin escape hatches (WORM bypass, .zddc edit
|
||||
// authority, profile admin scaffolds). State is carried in a
|
||||
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
|
||||
// → zddc.Principal{Elevated}.
|
||||
// the session turns on admin escape hatches (WORM bypass, recursive
|
||||
// delete, rearranging records, profile admin scaffolds — NOT config-edit,
|
||||
// which is standing). State is carried in a `zddc-elevate=1` cookie that
|
||||
// the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
|
||||
//
|
||||
// Toggle is by URL query param — `?admin=true` to arm, `?admin=false`
|
||||
// (or the red banner's "Drop admin" button) to drop — so it's reachable
|
||||
// from ANY zddc-server page, not just ones that render a header control.
|
||||
// The cookie is the sticky state: it persists across navigation for its
|
||||
// Max-Age window, so the param need not stay in the URL (we strip it).
|
||||
// Arming is gated on /.profile/access `can_elevate`, so only real admins
|
||||
// can set it; a non-admin's ?admin=true is a silent no-op.
|
||||
// This module owns the STATE (cookie, armed chrome/banner, ephemeral
|
||||
// lifecycle, the change event) + exposes setOn/setOff/isElevated. The
|
||||
// on-page elevate CONTROL lives in the shared profile menu
|
||||
// (shared/profile-menu.js) — an "Admin mode" item shown only to
|
||||
// can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
|
||||
// into any URL is also honoured (gated on can_elevate), for deep links /
|
||||
// scripting.
|
||||
//
|
||||
// Applying the cookie reloads to the cleaned URL so the server re-renders
|
||||
// under the new state (admin scaffolds in some tool HTML are server-
|
||||
// rendered, so a client-only flip wouldn't reach them). The red viewport
|
||||
// border + banner (applyArmedChrome) reflect the cookie on every load.
|
||||
// Admin mode is EPHEMERAL — scoped to the page you turned it on:
|
||||
// * the cookie is a SESSION cookie (no Max-Age), and
|
||||
// * we clear it on `pagehide`, so navigating away / closing the tab
|
||||
// drops admin (you re-arm deliberately on the next page).
|
||||
// Because of that we apply state IN PLACE (no reload — a reload's pagehide
|
||||
// would race the clear). SPAs that server-render elevation-dependent data
|
||||
// (e.g. browse's listing verbs) listen for the `zddc:elevationchange`
|
||||
// event we emit and re-fetch. The red viewport border + banner
|
||||
// (applyArmedChrome) reflect the cookie, kept in sync on every change.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
|
|
@ -38,16 +43,43 @@
|
|||
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';
|
||||
// shapes. No Max-Age → a SESSION cookie: it dies with the tab
|
||||
// and, combined with the pagehide handler below, is cleared the
|
||||
// moment you leave the page. Admin powers never silently
|
||||
// outlive the page you armed them on.
|
||||
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
|
||||
} else {
|
||||
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
|
||||
}
|
||||
}
|
||||
|
||||
// emitChange notifies same-page listeners (SPAs that server-render
|
||||
// elevation-dependent data, e.g. browse's listing verbs / editor
|
||||
// affordances) so they can re-fetch without a full reload.
|
||||
function emitChange() {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('zddc:elevationchange', {
|
||||
detail: { elevated: isElevated() }
|
||||
}));
|
||||
} catch (_e) { /* CustomEvent unsupported — non-fatal */ }
|
||||
}
|
||||
|
||||
// setOn / setOff are the single funnel for every arm/drop path (the
|
||||
// profile menu's Admin mode item, the ?admin= URL param, the banner's
|
||||
// Drop button). Each flips the cookie, re-paints the armed chrome, and
|
||||
// emits the change — no reload. The profile menu listens for the change
|
||||
// event to keep its checkbox + armed indicator in sync.
|
||||
function setOn() {
|
||||
setElevated(true);
|
||||
applyArmedChrome(true);
|
||||
emitChange();
|
||||
}
|
||||
function setOff() {
|
||||
setElevated(false);
|
||||
applyArmedChrome(false);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var resp = await fetch('/.profile/access', {
|
||||
|
|
@ -93,34 +125,26 @@
|
|||
return u.pathname + (qs ? '?' + qs : '') + u.hash;
|
||||
}
|
||||
|
||||
// handleAdminParam applies a ?admin= request. Returns true when a
|
||||
// navigation (reload) is underway so the caller can stop. Enabling is
|
||||
// gated on can_elevate — a non-admin who types ?admin=true just gets
|
||||
// the param stripped, never a misleading red border. Disabling is open
|
||||
// (anyone may drop a cookie they somehow hold).
|
||||
async function handleAdminParam() {
|
||||
// handleAdminParam applies a ?admin= request IN PLACE (no reload — see
|
||||
// the module header on why reloads would race the pagehide-clear).
|
||||
// Enabling is gated on can_elevate — a non-admin who types ?admin=true
|
||||
// just gets the param stripped, never a misleading red border.
|
||||
// Disabling is open (anyone may drop a cookie they somehow hold).
|
||||
// `access` (a prefetched /.profile/access, may be null) lets init reuse
|
||||
// its single fetch instead of issuing a second one.
|
||||
async function handleAdminParam(access) {
|
||||
var want = adminParam();
|
||||
if (want === null) return false;
|
||||
if (want === null) return;
|
||||
var clean = urlWithoutAdmin();
|
||||
if (want === isElevated()) {
|
||||
// Already in the requested state — just clean the URL, no reload.
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
return false;
|
||||
}
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
if (want === isElevated()) return; // already in the requested state
|
||||
if (want === true) {
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.can_elevate) {
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
return false;
|
||||
}
|
||||
setElevated(true);
|
||||
if (access === undefined) access = await fetchAccess();
|
||||
if (!access || !access.can_elevate) return; // silent no-op
|
||||
setOn();
|
||||
} else {
|
||||
setElevated(false);
|
||||
setOff();
|
||||
}
|
||||
// Navigate to the clean URL (a real load, so the server re-renders
|
||||
// under the new cookie) and replace history so Back is safe.
|
||||
window.location.replace(clean);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Page-wide affordances when elevation is active. The toggle alone
|
||||
|
|
@ -151,10 +175,7 @@
|
|||
+ '</button>';
|
||||
document.body.insertBefore(banner, document.body.firstChild);
|
||||
var off = banner.querySelector('#elevation-banner-off');
|
||||
if (off) off.addEventListener('click', function () {
|
||||
setElevated(false);
|
||||
window.location.reload();
|
||||
});
|
||||
if (off) off.addEventListener('click', function () { setOff(); });
|
||||
}
|
||||
} else if (banner) {
|
||||
banner.parentNode.removeChild(banner);
|
||||
|
|
@ -162,16 +183,30 @@
|
|||
}
|
||||
|
||||
async function init() {
|
||||
// Apply (or tear down) the red border + banner from the cookie on
|
||||
// every page load — admin mode is toggled by URL, but the armed
|
||||
// chrome must surface everywhere so the user can't accidentally
|
||||
// write through an elevated context on a page they didn't toggle.
|
||||
// file:// (offline FS-Access mode) has no server to elevate against.
|
||||
if (window.location.protocol === 'file:') return;
|
||||
|
||||
// Reflect the cookie's armed chrome on every load (a leftover from a
|
||||
// not-yet-fired pagehide, or an arrived-with ?admin link).
|
||||
applyArmedChrome(isElevated());
|
||||
|
||||
// Honour ?admin=true|false typed into any zddc-server URL. There's
|
||||
// no on-screen toggle anymore — the URL is the enable path and the
|
||||
// red banner's "Drop admin" button is the one-click disable.
|
||||
// Honour ?admin=true|false typed into any URL — handleAdminParam
|
||||
// fetches /.profile/access itself to gate arming on can_elevate. The
|
||||
// on-page elevate control lives in the shared profile menu
|
||||
// (shared/profile-menu.js), which calls setOn/setOff and listens for
|
||||
// zddc:elevationchange to keep its checkbox + armed ring in sync.
|
||||
await handleAdminParam();
|
||||
|
||||
// Admin mode is per-page: clear the cookie when the page goes away so
|
||||
// it never persists past a navigation.
|
||||
window.addEventListener('pagehide', function () {
|
||||
if (isElevated()) setElevated(false);
|
||||
});
|
||||
// bfcache can restore a page whose pagehide already cleared the
|
||||
// cookie — re-sync the armed chrome so chrome ≠ cookie can't happen.
|
||||
window.addEventListener('pageshow', function (e) {
|
||||
if (e.persisted) applyArmedChrome(isElevated());
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
|
@ -180,5 +215,10 @@
|
|||
init();
|
||||
}
|
||||
|
||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||
window.zddc.elevation = {
|
||||
isElevated: isElevated,
|
||||
setElevated: setElevated,
|
||||
setOn: setOn,
|
||||
setOff: setOff
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
111
shared/profile-menu.css
Normal file
111
shared/profile-menu.css
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/* shared/profile-menu.css — header account menu (upper-right).
|
||||
shared/profile-menu.js mounts a button into `.header-right` and toggles
|
||||
a dropdown with the signed-in email, Admin mode, Profile, Access tokens,
|
||||
and Sign out. Server mode only. */
|
||||
|
||||
.profile-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* The button: a small circular avatar showing the email initial. */
|
||||
.profile-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
}
|
||||
.profile-btn__avatar {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Armed (admin mode on): a red ring so the elevated state reads from the
|
||||
button even when the menu is closed — pairs with the page banner/frame. */
|
||||
.profile-btn--armed {
|
||||
box-shadow: 0 0 0 2px var(--danger, #dc3545);
|
||||
border-color: var(--danger, #dc3545);
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__panel {
|
||||
display: none;
|
||||
/* Fixed + JS-positioned from the button rect: an absolute panel gets
|
||||
trapped below the content layer by the app's stacking contexts, so
|
||||
anchor it to the viewport instead (profile-menu.js sets top/right). */
|
||||
position: fixed;
|
||||
min-width: 15rem;
|
||||
z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */
|
||||
background: var(--bg, #fff);
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: var(--radius, 6px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
|
||||
padding: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.profile-menu__panel.open { display: block; }
|
||||
|
||||
.profile-menu__id {
|
||||
padding: 0.35rem 0.55rem 0.45rem;
|
||||
}
|
||||
.profile-menu__email {
|
||||
font-weight: 600;
|
||||
color: var(--text, #222);
|
||||
word-break: break-all;
|
||||
}
|
||||
.profile-menu__role {
|
||||
margin-top: 0.1rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__sep {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0;
|
||||
background: var(--border, #eee);
|
||||
}
|
||||
|
||||
.profile-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: var(--radius, 4px);
|
||||
color: var(--text, #222);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.profile-menu__item:hover {
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.profile-menu__toggle { cursor: pointer; }
|
||||
.profile-menu__check {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger, #dc3545);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.profile-menu__toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.profile-menu__hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
165
shared/profile-menu.js
Normal file
165
shared/profile-menu.js
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// shared/profile-menu.js — account menu in the header's upper-right.
|
||||
//
|
||||
// Replaces the old floating elevation toggle. Admin mode is now one item in
|
||||
// this dropdown, alongside the signed-in email, Profile, and Access tokens.
|
||||
// Mounts into the tool header's `.header-right` cluster (every tool ships one)
|
||||
// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome
|
||||
// / ephemeral state machine stays in shared/elevation.js.
|
||||
//
|
||||
// No logout: authentication is the upstream proxy's concern (oauth2-proxy /
|
||||
// Authelia) — ZDDC owns no session, so it doesn't touch sign-out.
|
||||
//
|
||||
// Server mode only: it reads /.profile/access for the email + can_elevate.
|
||||
// On file:// (offline FS-Access mode) there's no server account, so nothing
|
||||
// renders.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.zddc) window.zddc = {};
|
||||
if (window.zddc.profileMenu) return;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var r = await fetch('/.profile/access', {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (_e) { return null; }
|
||||
}
|
||||
|
||||
var elevation = null;
|
||||
var panelEl = null, btnEl = null, adminInput = null;
|
||||
|
||||
function isElevated() {
|
||||
return !!(elevation && elevation.isElevated && elevation.isElevated());
|
||||
}
|
||||
|
||||
// Keep the button's armed ring + the menu checkbox in lockstep with the
|
||||
// elevation cookie (flipped here, by ?admin=, or by the banner's Drop).
|
||||
function syncArmed() {
|
||||
if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated());
|
||||
if (adminInput) adminInput.checked = isElevated();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (panelEl) panelEl.classList.remove('open');
|
||||
if (btnEl) btnEl.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
// The panel is position:fixed (to escape the app's stacking contexts), so
|
||||
// anchor it to the button rect — top just below it, right-aligned.
|
||||
function positionPanel() {
|
||||
var r = btnEl.getBoundingClientRect();
|
||||
panelEl.style.top = (r.bottom + 4) + 'px';
|
||||
panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
|
||||
panelEl.style.left = 'auto';
|
||||
}
|
||||
function toggleMenu() {
|
||||
if (!panelEl) return;
|
||||
var open = panelEl.classList.toggle('open');
|
||||
if (open) positionPanel();
|
||||
btnEl.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function linkItem(text, href) {
|
||||
var a = el('a', 'profile-menu__item', text);
|
||||
a.href = href;
|
||||
a.setAttribute('role', 'menuitem');
|
||||
return a;
|
||||
}
|
||||
|
||||
function build(access) {
|
||||
var wrap = el('div', 'profile-menu');
|
||||
|
||||
btnEl = el('button', 'btn btn-secondary profile-btn');
|
||||
btnEl.type = 'button';
|
||||
btnEl.id = 'profile-btn';
|
||||
btnEl.title = 'Account: ' + (access.email || 'signed in');
|
||||
btnEl.setAttribute('aria-haspopup', 'menu');
|
||||
btnEl.setAttribute('aria-expanded', 'false');
|
||||
var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase();
|
||||
btnEl.appendChild(el('span', 'profile-btn__avatar', initial));
|
||||
btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); });
|
||||
wrap.appendChild(btnEl);
|
||||
|
||||
panelEl = el('div', 'profile-menu__panel');
|
||||
panelEl.setAttribute('role', 'menu');
|
||||
|
||||
var id = el('div', 'profile-menu__id');
|
||||
id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)'));
|
||||
if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin'));
|
||||
else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin'));
|
||||
panelEl.appendChild(id);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
|
||||
// Admin mode — only offered to principals who actually have admin
|
||||
// authority somewhere (can_elevate). Drops automatically on leave.
|
||||
if (access.can_elevate && elevation) {
|
||||
var row = el('label', 'profile-menu__item profile-menu__toggle');
|
||||
adminInput = document.createElement('input');
|
||||
adminInput.type = 'checkbox';
|
||||
adminInput.className = 'profile-menu__check';
|
||||
adminInput.checked = isElevated();
|
||||
adminInput.addEventListener('change', function () {
|
||||
if (adminInput.checked) elevation.setOn(); else elevation.setOff();
|
||||
});
|
||||
row.appendChild(adminInput);
|
||||
var txt = el('span', 'profile-menu__toggle-label');
|
||||
txt.appendChild(el('span', null, 'Admin mode'));
|
||||
txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page'));
|
||||
row.appendChild(txt);
|
||||
panelEl.appendChild(row);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
}
|
||||
|
||||
panelEl.appendChild(linkItem('Profile', '/.profile'));
|
||||
panelEl.appendChild(linkItem('Access tokens', '/.tokens'));
|
||||
// No "Sign out": authentication is the upstream proxy's concern
|
||||
// (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it
|
||||
// doesn't render a logout affordance.
|
||||
|
||||
// Portal the panel to <body>, not inside the header: the app's
|
||||
// layout creates stacking contexts that trap even a fixed+high
|
||||
// z-index panel below the content. As a direct body child it sits in
|
||||
// the root stacking context and reliably overlays everything.
|
||||
// position:fixed + positionPanel() keep it anchored to the button.
|
||||
document.body.appendChild(panelEl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (window.location.protocol === 'file:') return;
|
||||
elevation = window.zddc.elevation || null;
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.email) return; // unauthenticated / non-zddc backend
|
||||
var host = document.querySelector('.header-right');
|
||||
if (!host) return;
|
||||
|
||||
host.appendChild(build(access));
|
||||
syncArmed();
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (panelEl && panelEl.classList.contains('open')
|
||||
&& !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
|
||||
window.addEventListener('zddc:elevationchange', syncArmed);
|
||||
|
||||
window.zddc.profileMenu = { close: closeMenu };
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
|
@ -22,6 +22,7 @@ concat_files \
|
|||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
"../shared/logo.css" \
|
||||
"../shared/context-menu.css" \
|
||||
"css/table.css" \
|
||||
|
|
@ -42,6 +43,7 @@ concat_files \
|
|||
"../shared/logo.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/profile-menu.js" \
|
||||
"../shared/cap.js" \
|
||||
"../shared/context-menu.js" \
|
||||
"js/mode.js" \
|
||||
|
|
@ -58,6 +60,7 @@ concat_files \
|
|||
"js/clipboard.js" \
|
||||
"js/export.js" \
|
||||
"js/render.js" \
|
||||
"js/api-actions.js" \
|
||||
"js/main.js" \
|
||||
"../form/js/app.js" \
|
||||
"../form/js/context.js" \
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
/* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */
|
||||
|
||||
.table-main {
|
||||
padding: var(--spacing-md);
|
||||
/* Vertical breathing room + clear left/right gutters so the table isn't
|
||||
flush to the viewport edges. */
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
|
@ -207,3 +209,32 @@
|
|||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── api-actions.js: create modal + per-row delete (e.g. /.tokens) ─────────── */
|
||||
.api-modal__overlay {
|
||||
position: fixed; inset: 0; z-index: 9500;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.api-modal {
|
||||
background: var(--bg, #fff); color: var(--text, #222);
|
||||
border: 1px solid var(--border, #ccc); border-radius: var(--radius, 6px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
padding: 1.1rem 1.2rem; width: min(28rem, 92vw);
|
||||
}
|
||||
.api-modal__title { margin: 0 0 .8rem; font-size: 1.1rem; }
|
||||
.api-modal__field { display: flex; flex-direction: column; gap: .25rem; margin-bottom: .7rem; font-size: .85rem; }
|
||||
.api-modal__field input {
|
||||
padding: .4rem .5rem; font: inherit;
|
||||
border: 1px solid var(--border, #ccc); border-radius: var(--radius, 4px);
|
||||
background: var(--bg, #fff); color: var(--text, #222);
|
||||
}
|
||||
.api-modal__actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: .8rem; }
|
||||
.api-modal__err { color: var(--danger, #b00020); font-size: .82rem; margin: .2rem 0; }
|
||||
.api-modal__secret-label { margin: 0 0 .5rem; font-size: .9rem; }
|
||||
.api-modal__secret {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem;
|
||||
word-break: break-all; padding: .6rem .7rem; border-radius: var(--radius, 4px);
|
||||
background: var(--bg-alt, #f3f4f6); border: 1px solid var(--border, #ccc);
|
||||
}
|
||||
.api-revoke { white-space: nowrap; margin-left: .6rem; float: right; }
|
||||
|
|
|
|||
257
tables/js/api-actions.js
Normal file
257
tables/js/api-actions.js
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
// api-actions.js — generic "tables over an API collection" layer.
|
||||
//
|
||||
// When the injected #table-context carries an `apiActions` block, this turns
|
||||
// the otherwise read-only table into a managed collection backed by a REST
|
||||
// endpoint, WITHOUT touching the file-save/row-ops machinery (which is bound
|
||||
// to <dir>/*.yaml row files). It drives create + per-row delete against the
|
||||
// configured URLs and reloads on success (the server re-renders the fresh
|
||||
// list). First consumer: the self-service token page at /.tokens.
|
||||
//
|
||||
// apiActions: {
|
||||
// create: { url, title?, fields:[{name,label,placeholder?,type?}], secretField?, secretLabel? },
|
||||
// deleteRow: { urlTemplate (with {id}), label?, confirm? } // {id} ← row's data-url
|
||||
// }
|
||||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
function ctxObj() {
|
||||
return (app && app.context) || {};
|
||||
}
|
||||
function cfg() {
|
||||
return ctxObj().apiActions || null;
|
||||
}
|
||||
// Active when the table is an API collection (apiActions) OR a read-only
|
||||
// server-injected view (readOnly) — either way the file-model toolbar
|
||||
// buttons (+ Add row / Save) don't apply and are hidden.
|
||||
function active() {
|
||||
return !!(cfg() || ctxObj().readOnly);
|
||||
}
|
||||
|
||||
function el(tag, attrs, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (attrs) Object.keys(attrs).forEach(function (k) { e.setAttribute(k, attrs[k]); });
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────────────
|
||||
var createMounted = false;
|
||||
function mountCreate(c) {
|
||||
if (createMounted) return;
|
||||
var bar = document.querySelector('.table-toolbar__left') || document.getElementById('table-toolbar');
|
||||
if (!bar) return;
|
||||
// The native "+ Add row" posts to the form-create file endpoint, which
|
||||
// doesn't apply to an API collection — hide it; this button replaces it.
|
||||
var native = document.getElementById('table-add-row');
|
||||
if (native) native.hidden = true;
|
||||
var btn = el('button', { type: 'button', class: 'btn btn-primary btn-sm', id: 'api-create-btn' }, '+ ' + (c.title || 'New'));
|
||||
btn.addEventListener('click', function () { openCreate(c); });
|
||||
bar.appendChild(btn);
|
||||
createMounted = true;
|
||||
}
|
||||
|
||||
function openCreate(c) {
|
||||
var overlay = el('div', { class: 'api-modal__overlay' });
|
||||
var modal = el('div', { class: 'api-modal' });
|
||||
modal.appendChild(el('h2', { class: 'api-modal__title' }, c.title || 'New'));
|
||||
var form = el('form', { class: 'api-modal__form' });
|
||||
var inputs = {};
|
||||
(c.fields || []).forEach(function (f) {
|
||||
var lab = el('label', { class: 'api-modal__field' });
|
||||
lab.appendChild(el('span', null, (f.label || f.name) + (f.required ? ' *' : '')));
|
||||
var inp = el('input', { type: f.type || 'text' });
|
||||
if (f.placeholder) inp.setAttribute('placeholder', f.placeholder);
|
||||
if (f.required) inp.required = true;
|
||||
inputs[f.name] = inp;
|
||||
lab.appendChild(inp);
|
||||
form.appendChild(lab);
|
||||
});
|
||||
var err = el('div', { class: 'api-modal__err', hidden: 'hidden' });
|
||||
form.appendChild(err);
|
||||
var actions = el('div', { class: 'api-modal__actions' });
|
||||
var cancel = el('button', { type: 'button', class: 'btn btn-secondary btn-sm' }, 'Cancel');
|
||||
var submit = el('button', { type: 'submit', class: 'btn btn-primary btn-sm' }, 'Create');
|
||||
actions.appendChild(cancel); actions.appendChild(submit);
|
||||
form.appendChild(actions);
|
||||
modal.appendChild(form);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
var firstInput = form.querySelector('input');
|
||||
if (firstInput) firstInput.focus();
|
||||
|
||||
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
|
||||
cancel.addEventListener('click', close);
|
||||
overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); });
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
err.hidden = true;
|
||||
var missing = (c.fields || []).filter(function (f) { return f.required && !inputs[f.name].value.trim(); });
|
||||
if (missing.length) {
|
||||
err.textContent = 'Required: ' + missing.map(function (f) { return f.label || f.name; }).join(', ');
|
||||
err.hidden = false;
|
||||
return;
|
||||
}
|
||||
var body = {};
|
||||
(c.fields || []).forEach(function (f) {
|
||||
var v = inputs[f.name].value.trim();
|
||||
if (!v) return;
|
||||
// Date fields → RFC3339 so the Go time.Time decoder accepts them.
|
||||
body[f.name] = (f.type === 'date') ? new Date(v + 'T00:00:00').toISOString() : v;
|
||||
});
|
||||
// Constant fields the server requires but the user doesn't set
|
||||
// (e.g. project create's parent="/").
|
||||
if (c.fixed) Object.keys(c.fixed).forEach(function (k) { body[k] = c.fixed[k]; });
|
||||
submit.disabled = true;
|
||||
fetch(c.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(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) {
|
||||
submit.disabled = false;
|
||||
err.textContent = 'Create failed: ' + res.status + ' ' + res.text;
|
||||
err.hidden = false;
|
||||
return;
|
||||
}
|
||||
close();
|
||||
var secret = '';
|
||||
if (c.secretField) {
|
||||
try { secret = (JSON.parse(res.text) || {})[c.secretField] || ''; } catch (_e) { /* ignore */ }
|
||||
}
|
||||
if (secret) showSecret(c.secretLabel || 'New secret (shown once):', secret);
|
||||
else location.reload();
|
||||
}).catch(function (e2) {
|
||||
submit.disabled = false;
|
||||
err.textContent = 'Create failed: ' + (e2 && e2.message ? e2.message : e2);
|
||||
err.hidden = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showSecret(label, secret) {
|
||||
var overlay = el('div', { class: 'api-modal__overlay' });
|
||||
var modal = el('div', { class: 'api-modal' });
|
||||
modal.appendChild(el('p', { class: 'api-modal__secret-label' }, label));
|
||||
var box = el('div', { class: 'api-modal__secret' }, secret);
|
||||
modal.appendChild(box);
|
||||
var actions = el('div', { class: 'api-modal__actions' });
|
||||
var copy = el('button', { type: 'button', class: 'btn btn-secondary btn-sm' }, 'Copy');
|
||||
copy.addEventListener('click', function () {
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(secret);
|
||||
copy.textContent = 'Copied';
|
||||
});
|
||||
var done = el('button', { type: 'button', class: 'btn btn-primary btn-sm' }, 'Done');
|
||||
done.addEventListener('click', function () { location.reload(); });
|
||||
actions.appendChild(copy); actions.appendChild(done);
|
||||
modal.appendChild(actions);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// ── Per-row delete ──────────────────────────────────────────────────────
|
||||
function ensureRowDelete(d) {
|
||||
var tbody = document.querySelector('#table-root tbody');
|
||||
if (!tbody) return;
|
||||
var trs = tbody.querySelectorAll('tr');
|
||||
for (var i = 0; i < trs.length; i++) {
|
||||
var tr = trs[i];
|
||||
if (tr.querySelector('.api-revoke')) continue;
|
||||
var id = tr.getAttribute('data-url');
|
||||
if (!id) continue;
|
||||
var cell = tr.lastElementChild;
|
||||
if (!cell) continue;
|
||||
var b = el('button', { type: 'button', class: 'btn btn-secondary btn-sm api-revoke' }, d.label || 'Delete');
|
||||
(function (rowId) {
|
||||
b.addEventListener('click', function () { revoke(d, rowId); });
|
||||
})(id);
|
||||
cell.appendChild(b);
|
||||
}
|
||||
}
|
||||
|
||||
function revoke(d, id) {
|
||||
if (d.confirm && !window.confirm(d.confirm)) return;
|
||||
var url = d.urlTemplate.replace('{id}', encodeURIComponent(id));
|
||||
fetch(url, { method: 'DELETE', credentials: 'same-origin' }).then(function (r) {
|
||||
if (r.ok || r.status === 204) location.reload();
|
||||
else r.text().then(function (t) { window.alert('Delete failed: ' + r.status + ' ' + t); });
|
||||
}).catch(function (e) { window.alert('Delete failed: ' + (e && e.message ? e.message : e)); });
|
||||
}
|
||||
|
||||
// Suppress the file-model toolbar affordances that don't apply to an API
|
||||
// collection: native "+ Add row" (posts to the form-create file endpoint)
|
||||
// and "Save" (flushes dirty row files). Re-run each tick in case main.js
|
||||
// toggles them after us.
|
||||
function hideNative() {
|
||||
// Use inline display:none, not the [hidden] attr — the .btn display
|
||||
// rule overrides [hidden] and the buttons would stay visible.
|
||||
['table-add-row', 'table-save'].forEach(function (id) {
|
||||
var b = document.getElementById(id);
|
||||
if (b) b.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Per-row navigation: clicking a row opens its data-url (the project /
|
||||
// subtree it represents) — used by the profile "Effective access" table.
|
||||
// Clicks on inner controls (buttons/links/inputs) are left alone.
|
||||
function ensureRowNav() {
|
||||
var tbody = document.querySelector('#table-root tbody');
|
||||
if (!tbody) return;
|
||||
var trs = tbody.querySelectorAll('tr');
|
||||
for (var i = 0; i < trs.length; i++) {
|
||||
var tr = trs[i];
|
||||
if (tr.getAttribute('data-nav') === '1') continue;
|
||||
var url = tr.getAttribute('data-url');
|
||||
if (!url) continue;
|
||||
tr.setAttribute('data-nav', '1');
|
||||
tr.style.cursor = 'pointer';
|
||||
(function (target) {
|
||||
// Capture phase: fire before the tables editor's per-cell
|
||||
// click handlers (which would otherwise swallow the click on
|
||||
// read-only rows). Inner controls (buttons/links/inputs) still
|
||||
// opt out.
|
||||
tr.addEventListener('click', function (e) {
|
||||
if (e.target.closest('button, a, input')) return;
|
||||
window.location.href = target;
|
||||
}, true);
|
||||
})(url);
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!active()) return;
|
||||
hideNative();
|
||||
var c = cfg();
|
||||
if (!c) return; // read-only view: native buttons hidden, nothing more
|
||||
if (c.create) mountCreate(c.create);
|
||||
if (c.deleteRow) ensureRowDelete(c.deleteRow);
|
||||
if (c.rowNav) ensureRowNav();
|
||||
}
|
||||
|
||||
function start() {
|
||||
// app.context is set asynchronously by main.js (await context.load()).
|
||||
// Poll until it's present, then run once + observe the tbody so the
|
||||
// per-row buttons survive sort/filter re-renders.
|
||||
var tries = 0;
|
||||
var iv = setInterval(function () {
|
||||
if (active() || tries++ > 60) {
|
||||
clearInterval(iv);
|
||||
if (!active()) return;
|
||||
tick();
|
||||
var tbody = document.querySelector('#table-root tbody');
|
||||
if (tbody && window.MutationObserver) {
|
||||
new MutationObserver(function () { tick(); }).observe(tbody, { childList: true });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})(window.tablesApp = window.tablesApp || {});
|
||||
|
|
@ -26,12 +26,6 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -48,12 +48,16 @@ test.describe('/.tokens self-service token UI', () => {
|
|||
expect(r.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('authenticated GET /.tokens renders the page with email', async ({ page }) => {
|
||||
test('authenticated GET /.tokens renders the tokens table with email', async ({ page }) => {
|
||||
// The page now renders through the shared tables engine (header chrome
|
||||
// + declarative columns), not the bespoke skeleton: the title lives in
|
||||
// #table-title, the signed-in email in the table description, create is
|
||||
// the apiActions "+ New token" button, and the grid is #table-root.
|
||||
await page.goto(`${server.baseURL}/.tokens`);
|
||||
await expect(page.locator('h1')).toHaveText(/API tokens/i);
|
||||
await expect(page.locator('.who')).toContainText(TEST_EMAIL);
|
||||
await expect(page.locator('#create')).toBeVisible();
|
||||
await expect(page.locator('#tokens')).toBeVisible();
|
||||
await expect(page.locator('#table-title')).toHaveText(/API tokens/i);
|
||||
await expect(page.locator('#table-description')).toContainText(TEST_EMAIL);
|
||||
await expect(page.locator('#api-create-btn')).toBeVisible();
|
||||
await expect(page.locator('#table-root')).toBeVisible();
|
||||
});
|
||||
|
||||
test('GET /.api/tokens initially returns empty list', async ({ request }) => {
|
||||
|
|
@ -64,47 +68,43 @@ test.describe('/.tokens self-service token UI', () => {
|
|||
expect(list.filter(t => t.email === TEST_EMAIL)).toEqual([]);
|
||||
});
|
||||
|
||||
test('create token via the page → plaintext shown once → list contains the new entry', async ({ page }) => {
|
||||
test('create token via the page → plaintext shown once → list contains it → revoke', async ({ page }) => {
|
||||
page.on('dialog', d => d.accept()); // auto-accept the revoke confirm()
|
||||
await page.goto(`${server.baseURL}/.tokens`);
|
||||
|
||||
// Wait for the inline JS's initial refresh() so we know the
|
||||
// table is populated (or shows "No tokens issued yet.").
|
||||
await expect(page.locator('#tokens tbody')).not.toBeEmpty();
|
||||
|
||||
// Fill the form and submit.
|
||||
// Create via the apiActions "+ New token" modal.
|
||||
await page.locator('#api-create-btn').click();
|
||||
await expect(page.locator('.api-modal')).toBeVisible();
|
||||
const description = `playwright-${Date.now()}`;
|
||||
await page.fill('#desc', description);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.locator('.api-modal input').first().fill(description);
|
||||
await page.locator('.api-modal button[type="submit"]').click();
|
||||
|
||||
// The plaintext token appears in #created div.token-secret —
|
||||
// shown exactly once per the API contract.
|
||||
const secret = page.locator('#created .token-secret');
|
||||
// The plaintext token is shown exactly once, in the secret dialog.
|
||||
const secret = page.locator('.api-modal__secret');
|
||||
await expect(secret).toBeVisible();
|
||||
const plaintext = (await secret.textContent()).trim();
|
||||
expect(plaintext.length).toBeGreaterThan(20);
|
||||
expect(plaintext).not.toContain('<');
|
||||
expect(plaintext).not.toContain('"');
|
||||
|
||||
// The token appears in the table.
|
||||
const row = page.locator('#tokens tbody tr', { hasText: description });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// Verify via the API too — the listed token's description matches.
|
||||
// Verify via the API while the dialog is up.
|
||||
const r = await page.request.get(`${server.baseURL}/.api/tokens`);
|
||||
const list = await r.json();
|
||||
const matches = list.filter(t => t.description === description);
|
||||
const matches = (await r.json()).filter(t => t.description === description);
|
||||
expect(matches.length).toBe(1);
|
||||
expect(matches[0].email).toBe(TEST_EMAIL);
|
||||
|
||||
// Revoke via the row's button. The page's confirm() dialog needs
|
||||
// to be auto-accepted.
|
||||
page.on('dialog', d => d.accept());
|
||||
await row.locator('button.danger').click();
|
||||
// Done reloads; the new token appears as a row.
|
||||
await page.locator('.api-modal button:has-text("Done")').click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
const row = page.locator('#table-root tbody tr', { hasText: description });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// Token should disappear from the table.
|
||||
await expect(page.locator('#tokens tbody tr', { hasText: description })).toHaveCount(0);
|
||||
// Revoke via the row's button (reloads on success).
|
||||
await row.locator('.api-revoke').click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('#table-root tbody tr', { hasText: description })).toHaveCount(0);
|
||||
|
||||
// And from the API list.
|
||||
// And gone from the API list.
|
||||
const after = await (await page.request.get(`${server.baseURL}/.api/tokens`)).json();
|
||||
expect(after.filter(t => t.description === description)).toEqual([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ concat_files \
|
|||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/base.css" \
|
||||
"css/layout.css" \
|
||||
|
|
@ -87,6 +88,7 @@ concat_files \
|
|||
"js/focus.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/profile-menu.js" \
|
||||
"../shared/cap.js" \
|
||||
"js/main.js" \
|
||||
> "$js_raw"
|
||||
|
|
|
|||
|
|
@ -51,12 +51,6 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
|
|||
</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 type="button" id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button type="button" id="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button>
|
||||
</div>
|
||||
|
|
|
|||
232
zddc/GRAMMAR.md
Normal file
232
zddc/GRAMMAR.md
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
# The `.zddc` grammar
|
||||
|
||||
This is the authoritative reference for the `.zddc` policy language: every key,
|
||||
its type, how it composes across the cascade, and how the engine turns a tree of
|
||||
`.zddc` files into an access decision. The design intent is **policy-as-data**:
|
||||
operators express *all* per-folder behaviour in `.zddc`; `zddc-server` is the
|
||||
enforcement engine that reads and applies it. Nothing here is hardcoded to
|
||||
folder *names* — the embedded defaults (the per-depth tree under
|
||||
`internal/zddc/defaults/`, exported as a `.zddc.zip` by `show-defaults`) are
|
||||
just the bottom-most policy in the cascade and are fully overridable.
|
||||
|
||||
> Status: the declarative grammar below is complete and enforced today. A
|
||||
> future **sandboxed expression layer** (`when:` conditions — see
|
||||
> [§7](#7-extension-point-when-expressions)) is reserved for attribute-based
|
||||
> rules that data alone can't express; it is *not* yet implemented.
|
||||
|
||||
The grammar has two executable backings, kept in lockstep with this document:
|
||||
|
||||
- **Layer 1 — the engine enforces whatever a `.zddc` says** (storage-agnostic):
|
||||
`internal/policy/policy_test.go` (cascade scenarios) + `internal/zddc/{acl,
|
||||
roles,worm}_test.go` + `internal/policy/parity_test.go` (Go ↔ OPA/Rego parity).
|
||||
- **Layer 2 — the shipped defaults are correct**: `internal/handler/
|
||||
defaults_matrix_test.go` (role × canonical-path × verb truth table).
|
||||
|
||||
---
|
||||
|
||||
## 1. Document model
|
||||
|
||||
A `.zddc` file is YAML. A directory's *effective policy* is the **cascade**: the
|
||||
ordered chain of `.zddc` files from the deployment root down to that directory,
|
||||
plus the embedded defaults at the bottom. Levels are indexed **root (0) → leaf
|
||||
(last)**.
|
||||
|
||||
Four things contribute to a level beyond the on-disk file:
|
||||
|
||||
1. **Embedded defaults** — the per-depth tree under `internal/zddc/defaults/`
|
||||
(assembled into a nested `paths:` `ZddcFile`), always the bottom of the chain
|
||||
(unless fenced off; see [`inherit`](#inherit)).
|
||||
2. **`.zddc.zip` policy bundles** — a `.zddc.zip` at *any* directory mounts a
|
||||
policy subtree there: its members (paths with `*` wildcards) are resolved
|
||||
like `paths:` and merged UNDER the on-disk `.zddc`. With `inherit:false` +
|
||||
`acl.inherit:false` in its root member it becomes a self-contained island.
|
||||
The embedded defaults are simply the bundle mounted at the deployment root.
|
||||
3. **Virtual `paths:` contributions** — an ancestor's [`paths:`](#paths) tree
|
||||
injects policy at descendant directories *that need not exist on disk*. This
|
||||
is why a brand-new project resolves usable policy at every canonical URL.
|
||||
4. **The on-disk `.zddc`** at the directory itself, which wins per-field over
|
||||
the virtual/ancestor contributions.
|
||||
|
||||
Because policy is resolved for *virtual* paths, **every authorization decision
|
||||
must compute the chain at the target's own directory** — never at the nearest
|
||||
directory that happens to exist on disk. (Resolving at the nearest *existing*
|
||||
ancestor was the cause of a real bug: a `document_controller` was denied
|
||||
`create` under a not-yet-materialised `working/<party>/` because the chain was
|
||||
computed at the project root.)
|
||||
|
||||
---
|
||||
|
||||
## 2. The decision pipeline
|
||||
|
||||
A single verb decision (`r` read, `w` write, `c` create, `d` delete, `a` admin)
|
||||
is computed in this fixed order — see `internal/policy/policy.go`
|
||||
(`InternalDecider.Allow`):
|
||||
|
||||
```
|
||||
1. Active-admin bypass → if the principal is an ELEVATED admin named in an
|
||||
admins: list anywhere on the chain, ALLOW everything
|
||||
(WORM included). The single escape hatch.
|
||||
2. WORM mask → if the directory is in a worm: zone, the effective
|
||||
verbs are (normal-grant & r) | (worm-grant & rc):
|
||||
write/delete/admin always stripped; create survives
|
||||
only for worm: members; read via either source.
|
||||
3. Normal cascade grant → otherwise, the cascade ACL decision (§4).
|
||||
```
|
||||
|
||||
Read/elevation rules that frame the pipeline:
|
||||
|
||||
- **Elevation is required for admin powers** (sudo-style). A principal is an
|
||||
*active* admin only when `Elevated` is true AND named (directly or via role)
|
||||
in an `admins:` list on the chain. Browser sessions elevate via the
|
||||
`zddc-elevate=1` cookie (set by `?admin=true`); bearer-token callers are
|
||||
elevated implicitly.
|
||||
- **Default-allow only on a truly empty tree**: if *no* `.zddc` exists anywhere
|
||||
on the chain (`HasAnyFile == false`), everything is allowed (a bare directory
|
||||
served with no policy is public). As soon as any `.zddc` exists, the default
|
||||
is deny.
|
||||
|
||||
---
|
||||
|
||||
## 3. ACL evaluation (the `acl.permissions` core)
|
||||
|
||||
`acl.permissions` maps a **principal pattern** → a **verb string**. A principal
|
||||
is an email-glob (`alice@x`, `*@acme.com`, `*`) or a **role name** (no `@`),
|
||||
which expands to the role's members (§[`roles`](#roles)). Verbs are any subset
|
||||
of `r w c d a`; the empty string `""` is an **explicit deny**.
|
||||
|
||||
Two composition rules — and they are **different on purpose**:
|
||||
|
||||
- **Within one level**, every matching entry (email + wildcard + role) is
|
||||
**unioned** — EXCEPT an explicit-deny (`""`) match, which zeroes the grant at
|
||||
that level (deny is more specific than a permissive role membership).
|
||||
- **Across levels**, the **deepest level that matches the principal wins** — it
|
||||
*replaces*, it does not add. A closer `.zddc` that grants you `cr` overrides
|
||||
an ancestor's `r`; an ancestor grant is consulted only if no closer level
|
||||
matches you at all.
|
||||
|
||||
> Mnemonic: *role membership unions up the tree; permissions take the deepest
|
||||
> match.* Conflating these is the most common reasoning error.
|
||||
|
||||
---
|
||||
|
||||
## 4. Key reference
|
||||
|
||||
`✱` = honored only in the **root** `.zddc`. Cascade column says how the key
|
||||
composes from leaf→root.
|
||||
|
||||
| Key | Type | Cascade | Meaning |
|
||||
|---|---|---|---|
|
||||
| `acl.permissions` | map principal→verbs | **deepest match wins** (§3) | the verb grants |
|
||||
| `roles` | map name→`{members:[], reset?}` | **members union** (a `reset:true` level starts fresh) | named principal groups; referenced by `acl`/`worm`/`admins` |
|
||||
| `admins` | list of principal | union (root = super-admin; deeper = subtree admin) | elevation-gated full bypass over that scope |
|
||||
| `worm` | list of principal | **union** | WORM zone: strips w/d/a for all; create survives only for listed (§2) |
|
||||
| `inherit` | bool (default true) | **fence** | `false` stops the cascade here — nothing below (incl. defaults) is visible |
|
||||
| `default_tool` | string | deepest non-empty | tool served at `<dir>` (no slash) — sugar for `views.dir.tool` |
|
||||
| `dir_tool` | string | deepest non-empty | tool served at `<dir>/` — sugar for `views.dir_slash.tool` |
|
||||
| `views` | map shape→`{tool,config}` | map-merge (child wins per shape) | per-URL-shape tool + `.zddc.d/`-resolved config |
|
||||
| `available_tools` | list | **concat-dedupe union** | tools the apps subsystem may auto-serve |
|
||||
| `auto_own` | bool | deepest non-nil | mkdir post-hook writes a creator-owned `.zddc` |
|
||||
| `auto_own_fenced` | bool | deepest non-nil | the auto-own `.zddc` is `inherit:false` (private to creator) |
|
||||
| `auto_own_roles` | list | deepest non-nil | roles also granted `rwcda` in the auto-own `.zddc` |
|
||||
| `history` | bool | **subtree-inheriting** (deepest set wins; crosses fences) | snapshot markdown edits to `.history/` |
|
||||
| `history_globs` | list | deepest non-empty (default `["*.md"]`) | which files history applies to |
|
||||
| `drop_target` | bool | **leaf-only** | this dir is a browse drag-drop upload zone |
|
||||
| `virtual` | bool | deepest non-nil | never materialise on disk; treat as virtual route |
|
||||
| `party_source` | string | **leaf-only** | a new `<party>/` here requires registration in `<source>/<party>.yaml` |
|
||||
| `convert` | `{client,project,contractor,project_number}` | per-key latest (leaf) wins | MD→{docx,html,pdf} template variables |
|
||||
| `field_codes` | map name→FieldCode | map-merge per code | tracking-number / record field vocabularies |
|
||||
| `records` | map pattern→RecordRule | map-merge per pattern, per-field | per-record-type rules (filename format, defaults, locked, row scope) |
|
||||
| `display` | map name→label | **leaf-only** (no upward cascade) | human labels for child entries |
|
||||
| `tables` | map stem→specpath | leaf-only | legacy directory-of-YAML table view |
|
||||
| `received_path` | string | leaf | links a workflow folder back to its canonical submittal |
|
||||
| `planned_review_date` / `planned_response_date` | ISO date | leaf | doc-controller commitments on the canonical submittal |
|
||||
| `title` | string | leaf | human title for the directory |
|
||||
| `paths` | map segment→`.zddc` | recursive virtual injection (on-disk wins) | apply policy at descendant segments that need not exist |
|
||||
|
||||
`FieldCode` and `RecordRule` are themselves small grammars (discriminated unions
|
||||
/ structured rules) — see the Go types in `internal/zddc/file.go` for their
|
||||
sub-fields; they are out of scope for this top-level reference.
|
||||
|
||||
---
|
||||
|
||||
## 5. `paths:` — virtual policy injection
|
||||
|
||||
`paths:` is what makes the grammar express a whole project shape from one file.
|
||||
Each key is a **single path segment** — a literal name or `*` (matches any
|
||||
segment) — and the value is a nested `.zddc` applied at the matching child
|
||||
directory. It recurses (`paths:` inside `paths:`). Matching prefers a literal
|
||||
key, then falls back to `*`.
|
||||
|
||||
Ancestor `paths:` contributions are merged into the effective `.zddc` at each
|
||||
level by `EffectivePolicy`; an on-disk `.zddc` at the matching directory wins
|
||||
per-field. The embedded defaults use exactly this to define the canonical
|
||||
project structure (`archive/`, `working/`, `staging/`, …) without any of those
|
||||
folders existing on disk.
|
||||
|
||||
> **The merge trap.** When you add a new top-level key, the per-level merge in
|
||||
> `walker.go` (`mergeOverlay`) must carry it from `paths:` contributions into
|
||||
> `chain.Levels`, or the key silently no-ops at default-driven paths. Verify a
|
||||
> new key with a *defaults-path* test, not just a hand-built `PolicyChain`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Reserved namespaces (not policy keys)
|
||||
|
||||
- `.zddc.d/` — per-directory admin-only reserve (tokens, logs, history,
|
||||
converted cache, view configs). 404 to non-admins at every depth; writes
|
||||
admin-gated. Not addressable as content.
|
||||
- `.zddc.zip` — a config bundle droppable at **any** directory. Its `.zddc`
|
||||
members (per-depth, `*` wildcards, individually replaceable) mount a policy
|
||||
subtree at that directory (see §1); it may also carry tool-HTML overrides.
|
||||
The shipped baseline is the embedded bundle at the deployment root
|
||||
(`show-defaults` exports it). Browsable only by an elevated admin. NOT a
|
||||
cascade key — it's resolved by the engine, not declared inside a `.zddc`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Extension point: `when:` expressions
|
||||
|
||||
The declarative keys above are *attribute-free* — they decide on principal +
|
||||
path + folder behaviour, not on runtime values. Attribute-based rules ("allow
|
||||
write only before the issue date", "creator may edit their own files",
|
||||
"requester.dept must equal folder.dept") are the one class data can't express.
|
||||
|
||||
The reserved mechanism is a **pure, sandboxed expression** attached to a grant —
|
||||
NOT embedded general-purpose code. Sketch (subject to design):
|
||||
|
||||
```yaml
|
||||
acl:
|
||||
permissions:
|
||||
"*@acme.com":
|
||||
verbs: rwc
|
||||
when: 'request.now < record.issue_date && request.email == record.author'
|
||||
```
|
||||
|
||||
Constraints that keep the engine an engine:
|
||||
- A single boolean expression in a **non-Turing-complete, side-effect-free**
|
||||
language (CEL or `expr`-lang), evaluated in-process per decision.
|
||||
- Inputs are a fixed, read-only context (`request`, `principal`, `record`,
|
||||
`folder`) — no I/O, no filesystem, no network, no unbounded loops.
|
||||
- A timeout + recursion bound; a failed/erroring expression denies.
|
||||
- This is distinct from raw JavaScript in `.zddc`, which is deliberately *not*
|
||||
adopted: it would put arbitrary code execution in the authorization path and
|
||||
break the "`.zddc` is data" guarantee.
|
||||
|
||||
For operators who want to replace the decision engine wholesale, the
|
||||
`Decider` interface already supports an external **OPA/Rego** policy server
|
||||
(`--policy-url`); `parity_test.go` keeps the built-in engine and the Rego policy
|
||||
in agreement. `when:` is the lightweight in-tree complement to that heavy
|
||||
out-of-process option.
|
||||
|
||||
---
|
||||
|
||||
## 8. Validation
|
||||
|
||||
`internal/zddc/validate.go` (`ValidateFile`) checks a `.zddc` structurally
|
||||
(known keys, verb charset, role/view shapes, path-safety of configs). The browse
|
||||
client mirrors a subset in `browse/js/preview-yaml.js`. **Formalizing these two
|
||||
into one machine-readable schema (JSON Schema, validated server-side via
|
||||
`internal/jsonschema` and shipped to the client) is the natural next step** —
|
||||
it removes the client/server drift and makes this document's §4 table
|
||||
machine-checkable. Until then, this file is the source of truth and the two
|
||||
test layers (above) are its behavioural guarantee.
|
||||
|
|
@ -380,7 +380,7 @@ roles:
|
|||
members: [dc@mycompany.com, alice@mycompany.com]
|
||||
```
|
||||
|
||||
Members are email patterns using the same glob syntax as legacy `acl.allow`. Underscore-prefixed names are conventional (`_doc_controller`, `_company`) but not magic. **Role membership UNIONS across the cascade** — a `.zddc` that defines `vendor_acme` again with one extra member *adds* that member to the inherited role; use `reset: true` on the role at a level to break the union (ancestor definitions above the reset are then excluded). Permission-map keys without `@` are treated as role references first; if no role of that name exists in the visible cascade, they fall back to legacy email-pattern matching (so `*@example.com` and bare `*` continue to work). The baked-in `defaults.zddc.yaml` ships two empty standard roles — `document_controller` and `project_team` — referenced by the default ACLs; a deployment populates their members.
|
||||
Members are email patterns using the same glob syntax as legacy `acl.allow`. Underscore-prefixed names are conventional (`_doc_controller`, `_company`) but not magic. **Role membership UNIONS across the cascade** — a `.zddc` that defines `vendor_acme` again with one extra member *adds* that member to the inherited role; use `reset: true` on the role at a level to break the union (ancestor definitions above the reset are then excluded). Permission-map keys without `@` are treated as role references first; if no role of that name exists in the visible cascade, they fall back to legacy email-pattern matching (so `*@example.com` and bare `*` continue to work). The baked-in default tree ships two empty standard roles — `document_controller` and `project_team` — referenced by the default ACLs; a deployment populates their members.
|
||||
|
||||
### Step 1: starter `.zddc`
|
||||
|
||||
|
|
@ -466,7 +466,7 @@ Behaviour:
|
|||
bypass all ACL evaluation, fence or no fence — that's the deliberate
|
||||
escape hatch for misfiled documents.
|
||||
- **WORM:** a `worm:` zone (declared by a `worm: [principal…]` key on a
|
||||
`.zddc` — the baked-in `defaults.zddc.yaml` puts it on
|
||||
`.zddc` — the baked-in default tree puts it on
|
||||
`archive/<party>/{received,issued}`) is independent of the `inherit:`
|
||||
fence; `inherit: false` does not change WORM behaviour. See
|
||||
"Canonical-folder behaviour via `.zddc` keys" below.
|
||||
|
|
@ -493,8 +493,8 @@ fence-aware role walk (`zddc/internal/zddc/roles.go`).
|
|||
**There are no hardcoded folder names.** The canonical project structure
|
||||
(`archive/`, `working/`, `staging/`, `reviewing/`; `archive/<party>/{mdl,
|
||||
incoming,received,issued}/`) and its built-in behaviours are described by a
|
||||
baked-in baseline `.zddc` — `zddc/internal/zddc/defaults.zddc.yaml`, the
|
||||
bottom layer of every cascade, dumpable with `zddc-server show-defaults` — that
|
||||
baked-in baseline `.zddc` — `zddc/internal/zddc/defaults/`, the
|
||||
bottom layer of every cascade, exportable as a `.zddc.zip` with `zddc-server show-defaults` — that
|
||||
uses a recursive `paths:` tree to declare subfolder rules even before those
|
||||
folders exist on disk. Operators override at the on-disk root (or any deeper
|
||||
level) by mirroring the structure and changing what they need; setting
|
||||
|
|
@ -525,8 +525,8 @@ download; write methods to a path inside a `.zip` are rejected (405). And
|
|||
`/dir/`, recursively, ACL-filtered (`Content-Disposition: attachment;
|
||||
filename="<dir>.zip"`).
|
||||
|
||||
The baked-in `defaults.zddc.yaml` is the authoritative, heavily-commented
|
||||
reference for all of the above — `zddc-server show-defaults` prints it.
|
||||
The baked-in default tree is the authoritative, heavily-commented
|
||||
reference for all of the above — `zddc-server show-defaults` exports it as a `.zddc.zip`.
|
||||
Implementation: `zddc/internal/zddc/walker.go` (`mergeOverlay`, the `paths:`
|
||||
walk), `lookups.go` (`DefaultToolAt`/`DirToolAt`/`AutoOwnAt`/…), `worm.go`,
|
||||
`roles.go`; the file API's mkdir hook (`zddc/internal/handler/fileapi.go`) and
|
||||
|
|
|
|||
|
|
@ -52,14 +52,19 @@ func main() {
|
|||
fmt.Print(policy.FederalRego)
|
||||
return
|
||||
case "show-defaults", "--show-defaults":
|
||||
// Dump the embedded baseline .zddc to stdout. Pipe into a
|
||||
// real file (e.g. $ZDDC_ROOT/.zddc) to start from the
|
||||
// shipped defaults and edit; the on-disk copy then
|
||||
// participates in the cascade alongside the embedded
|
||||
// layer (both contribute; child wins). To ignore the
|
||||
// embedded layer entirely after exporting, set
|
||||
// `inherit: false` at the top of the exported file.
|
||||
_, _ = os.Stdout.Write(zddc.EmbeddedDefaultsBytes())
|
||||
// Emit the embedded baseline as a .zddc.zip (per-depth policy
|
||||
// tree, "*" wildcard members) to stdout. Redirect into a bundle
|
||||
// (e.g. `> $ZDDC_ROOT/.zddc.zip`) to start from the shipped
|
||||
// defaults and edit/add/delete individual members; the bundle
|
||||
// participates in the cascade (child wins). Drop it at any
|
||||
// directory to mount a subtree; add inherit:false +
|
||||
// acl.inherit:false to fully replace the baseline there.
|
||||
b, err := zddc.EmbeddedDefaultsZip()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "show-defaults:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
_, _ = os.Stdout.Write(b)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -218,7 +223,7 @@ func main() {
|
|||
"no_auth", cfg.NoAuth)
|
||||
|
||||
// Bootstrap sanity: warn loudly (but don't fail) when the root .zddc
|
||||
// grants nobody anything. Embedded defaults.zddc.yaml ships with empty
|
||||
// grants nobody anything. Embedded internal/zddc/defaults/ ships with empty
|
||||
// role members, so a fresh deployment refuses every request until the
|
||||
// operator populates the file.
|
||||
warnIfNoBootstrap(cfg)
|
||||
|
|
@ -516,7 +521,7 @@ func setupApps(cfg config.Config) (*apps.Server, error) {
|
|||
}
|
||||
|
||||
// warnIfNoBootstrap fires a startup slog.Warn when the root .zddc grants
|
||||
// nobody anything — the embedded defaults.zddc.yaml ships with empty role
|
||||
// nobody anything — the embedded defaults ships with empty role
|
||||
// members, so a deployment without operator-populated admins / acl
|
||||
// permissions / role members refuses every request. Skipped under
|
||||
// --no-auth (auth disabled; warning would be redundant). Per-project
|
||||
|
|
@ -709,16 +714,20 @@ func splitZipPath(fsRoot, urlPath string) (zipAbs, member string, ok bool) {
|
|||
return "", "", false
|
||||
}
|
||||
|
||||
// activeAdminForBundle reports whether the request principal is an active
|
||||
// (elevated) admin over the directory that holds the .zddc.zip config bundle
|
||||
// referenced by urlPath. Mirrors handler.ActiveAdminForSidecar: the bundle is
|
||||
// existence-hidden config for everyone else, but an elevated admin over its
|
||||
// directory may browse its members and download it. Works for every bundle URL
|
||||
// shape (bare, trailing-slash listing, and <bundle>/<member>) since it keys off
|
||||
// the path segment that precedes the bundle name.
|
||||
func activeAdminForBundle(cfg config.Config, r *http.Request, urlPath string) bool {
|
||||
// configEditorForBundle reports whether the request principal holds STANDING
|
||||
// config-edit authority over the directory that holds the .zddc.zip config
|
||||
// bundle referenced by urlPath — a subtree admin (admins: cascade) or `a`-verb
|
||||
// holder, WITHOUT elevation. Both browsing the bundle's members and writing
|
||||
// them are gated by this: config you administer is visible+editable without a
|
||||
// toggle. The bundle is NOT wide-readable, because it packs many subtrees'
|
||||
// policy into one file — exposing it to every reader would leak a tightened
|
||||
// subtree's rules; per-level transparency is served by ServeZddcFile instead.
|
||||
// Elevation isn't required here; it only adds the WORM/destructive overrides
|
||||
// elsewhere. Works for every bundle URL shape (bare, trailing-slash listing,
|
||||
// and <bundle>/<member>) since it keys off the segment before the bundle name.
|
||||
func configEditorForBundle(cfg config.Config, r *http.Request, urlPath string) bool {
|
||||
p := handler.PrincipalFromContext(r)
|
||||
if !p.Elevated || p.Email == "" {
|
||||
if p.Email == "" {
|
||||
return false
|
||||
}
|
||||
parent := make([]string, 0)
|
||||
|
|
@ -730,7 +739,7 @@ func activeAdminForBundle(cfg config.Config, r *http.Request, urlPath string) bo
|
|||
}
|
||||
dir := filepath.Join(cfg.Root, filepath.FromSlash(strings.Join(parent, "/")))
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, dir)
|
||||
return zddc.IsAdminForChain(chain, p.Email)
|
||||
return zddc.IsConfigEditor(chain, p.Email)
|
||||
}
|
||||
|
||||
// dispatch routes a request to the appropriate handler.
|
||||
|
|
@ -783,6 +792,21 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
|
||||
// Recognised markdown front-matter fields + editor placeholder (JSON).
|
||||
// The browse markdown editor fetches this to hint the valid keys; it's
|
||||
// static, read-only, and leaks nothing, so no auth gate.
|
||||
if urlPath == handler.FrontMatterTemplatePath {
|
||||
handler.ServeFrontMatterTemplate(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// The .zddc JSON Schema (machine grammar) — drives the .zddc form view +
|
||||
// client validation. Static, read-only, no auth.
|
||||
if urlPath == handler.ZddcSchemaPath {
|
||||
handler.ServeZddcSchema(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Auth check endpoints — machine-only forward_auth targets used by
|
||||
// upstream proxies (e.g. the dev-shell pod's Caddy in front of
|
||||
// code-server) to gate routes on root-admin status. Handled before
|
||||
|
|
@ -831,16 +855,18 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
}
|
||||
|
||||
// The site-root config bundle <ZDDC_ROOT>/.zddc.zip is config, not
|
||||
// ordinary content: existence-hidden over HTTP for everyone EXCEPT an
|
||||
// active (elevated) admin over its directory, who may browse it in the
|
||||
// file tree. For an admin every bundle URL falls through to normal
|
||||
// handling — GET <bundle>/ lists its members (the zip-as-directory
|
||||
// intercept below), GET <bundle>/member extracts one, and a bare
|
||||
// GET <bundle> downloads it. Everyone else gets 404 for every form,
|
||||
// which also keeps individual members from being fetched by name. The
|
||||
// server reads members from the filesystem internally (apps.Bundle) to
|
||||
// resolve tool HTML — that path never goes through dispatch, so this
|
||||
// gate doesn't affect resolution.
|
||||
// ordinary content: existence-hidden over HTTP for everyone EXCEPT a
|
||||
// standing config-editor over its directory (a subtree admin or `a`-verb
|
||||
// holder — NO elevation required), who may browse it in the file tree.
|
||||
// It's NOT wide-readable because one file packs many subtrees' policy;
|
||||
// per-level transparency is served by ServeZddcFile. For a config-editor
|
||||
// every bundle URL falls through to normal handling — GET <bundle>/ lists
|
||||
// its members (the zip-as-directory intercept below), GET <bundle>/member
|
||||
// extracts one, and a bare GET <bundle> downloads it. Everyone else gets
|
||||
// 404 for every form, which also keeps individual members from being
|
||||
// fetched by name. The server reads members from the filesystem internally
|
||||
// (apps.Bundle) to resolve tool HTML — that path never goes through
|
||||
// dispatch, so this gate doesn't affect resolution.
|
||||
bundlePath := false
|
||||
for _, seg := range segments {
|
||||
if strings.EqualFold(seg, apps.BundleName) {
|
||||
|
|
@ -848,7 +874,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
break
|
||||
}
|
||||
}
|
||||
if bundlePath && !activeAdminForBundle(cfg, r, urlPath) {
|
||||
if bundlePath && !configEditorForBundle(cfg, r, urlPath) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
|
@ -859,8 +885,11 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
// cascade summary so the user can see what's effective here. The
|
||||
// reserved-sidecar gate above already filtered out .zddc.d/.zddc, so
|
||||
// GET/HEAD land here for ordinary paths and PUT/DELETE/POST fall
|
||||
// through to ServeFileAPI.
|
||||
if handler.IsZddcFileRequest(urlPath) && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
|
||||
// through to ServeFileAPI. A .zddc *inside* a zip (".zip/…/.zddc", e.g.
|
||||
// a policy member of the .zddc.zip bundle) is NOT a real on-disk file —
|
||||
// it's served by the zip intercept below, so exclude it here.
|
||||
if handler.IsZddcFileRequest(urlPath) && !strings.Contains(strings.ToLower(urlPath), ".zip/") &&
|
||||
(r.Method == http.MethodGet || r.Method == http.MethodHead) {
|
||||
handler.ServeZddcFile(cfg, w, r)
|
||||
return
|
||||
}
|
||||
|
|
@ -958,6 +987,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
if strings.Contains(strings.ToLower(urlPath), ".zip/") {
|
||||
if zipAbs, member, ok := splitZipPath(cfg.Root, urlPath); ok {
|
||||
if handler.IsWriteMethod(r.Method) {
|
||||
// In-place editing is allowed ONLY inside the .zddc.zip config
|
||||
// bundle and ONLY for a standing config-editor over its dir
|
||||
// (the bundle gate above already 404s the bundle to everyone
|
||||
// else, so visibility ⇒ edit authority — no elevation). Content
|
||||
// zips — transmittal packages, WORM records — stay read-only.
|
||||
if strings.EqualFold(filepath.Base(zipAbs), apps.BundleName) &&
|
||||
configEditorForBundle(cfg, r, urlPath) {
|
||||
handler.ServeZipWrite(cfg, w, r, zipAbs, member)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "Zip archives are read-only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1181,11 +1181,16 @@ func TestDispatchBundleAdminView(t *testing.T) {
|
|||
t.Errorf("admin GET bare /.zddc.zip : status=%d, want 200 download", rec.Code)
|
||||
}
|
||||
|
||||
// Same admin un-elevated → 404 (sudo model: powers are per-request).
|
||||
if rec := do("/.zddc.zip/", "alice@x", false); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("un-elevated admin GET /.zddc.zip/ : status=%d, want 404", rec.Code)
|
||||
// Same admin un-elevated → STILL visible: config-edit is standing, so a
|
||||
// subtree admin browses the bundle without elevating (elevation only adds
|
||||
// the WORM/destructive overrides, not config visibility/edit).
|
||||
if rec := do("/.zddc.zip/", "alice@x", false); rec.Code != http.StatusOK {
|
||||
t.Errorf("un-elevated admin GET /.zddc.zip/ : status=%d, want 200 (standing config-edit)", rec.Code)
|
||||
}
|
||||
// Non-admin reader → 404 for listing AND by-name member (no leak).
|
||||
// Non-admin reader (bob has `r` but no admin/`a`) → 404 for listing AND
|
||||
// by-name member: the bundle is scoped to config-EDITORS, not all readers
|
||||
// (one file packs many subtrees' policy — per-level transparency is
|
||||
// ServeZddcFile's job, not the bundle's).
|
||||
if rec := do("/.zddc.zip/", "bob@x", true); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("non-admin GET /.zddc.zip/ : status=%d, want 404", rec.Code)
|
||||
}
|
||||
|
|
@ -1193,3 +1198,80 @@ func TestDispatchBundleAdminView(t *testing.T) {
|
|||
t.Errorf("non-admin GET member: status=%d, want 404 (no by-name leak)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchBundleAdminWrite locks in edit-in-place for the .zddc.zip config
|
||||
// bundle: an active admin can PUT/DELETE members (changing live policy), each
|
||||
// edit snapshots the prior version into an in-zip .history/, non-admins get 404
|
||||
// (the bundle gate), and content zips stay read-only.
|
||||
func TestDispatchBundleAdminWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n permissions:\n \"alice@x\": rwcda\nadmins:\n - alice@x\n")
|
||||
mustMkdir(t, filepath.Join(root, "Proj"))
|
||||
writeRootBundle(t, root, map[string]string{"browse.html": "<!doctype html>BUNDLE"})
|
||||
mustWriteZip(t, filepath.Join(root, "Proj", "Foo.zip"), map[string]string{"m.txt": "x"})
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildIndex: %v", err)
|
||||
}
|
||||
cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email", MaxWriteBytes: 64 * 1024}
|
||||
ring := handler.NewLogRing(10)
|
||||
appsSrv, err := setupApps(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("setupApps: %v", err)
|
||||
}
|
||||
do := func(method, path, email string, elevated bool, body []byte) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), handler.EmailKey, email)
|
||||
ctx = context.WithValue(ctx, handler.ElevatedKey, elevated)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// 1. Admin creates a policy member (governs the project level via "*").
|
||||
if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "alice@x", true,
|
||||
[]byte("acl:\n permissions:\n \"team@x\": rwc\n")); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("PUT new member: status=%d body=%s, want 201", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 2. The edit took effect on the live cascade (write invalidated the cache).
|
||||
zddc.InvalidateCache(root)
|
||||
chain, _ := zddc.EffectivePolicy(root, filepath.Join(root, "Proj"))
|
||||
if !zddc.EffectiveVerbs(chain, "team@x").Has(zddc.VerbC) {
|
||||
t.Errorf("bundle policy edit didn't reach the cascade: team@x lacks create at /Proj")
|
||||
}
|
||||
// 3. Edit again (existing member → snapshots to .history/).
|
||||
if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "alice@x", true,
|
||||
[]byte("acl:\n permissions:\n \"team@x\": r\n")); rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT overwrite: status=%d, want 200", rec.Code)
|
||||
}
|
||||
// 4. Read back the current member.
|
||||
if rec := do(http.MethodGet, "/.zddc.zip/Proj/.zddc", "alice@x", true, nil); !strings.Contains(rec.Body.String(), "\"team@x\": r") {
|
||||
t.Errorf("read-back body=%q, want the latest edit", rec.Body.String())
|
||||
}
|
||||
// 5. The in-zip history log records the edit (audited with the editor email).
|
||||
if rec := do(http.MethodGet, "/.zddc.zip/.history/Proj/.zddc/log.jsonl", "alice@x", true, nil); !strings.Contains(rec.Body.String(), "alice@x") {
|
||||
t.Errorf("history log=%q, want an alice@x entry", rec.Body.String())
|
||||
}
|
||||
// 5b. Un-elevated config-editor can ALSO write the bundle — config-edit is
|
||||
// standing for the bundle too, no toggle required.
|
||||
if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "alice@x", false,
|
||||
[]byte("acl:\n permissions:\n \"team@x\": rw\n")); rec.Code != http.StatusOK {
|
||||
t.Errorf("un-elevated config-editor PUT bundle: status=%d, want 200 (standing)", rec.Code)
|
||||
}
|
||||
// 6. Non-admin write → 404 (bundle existence-hidden to non config-editors).
|
||||
if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "bob@x", true, []byte("x")); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("non-admin PUT: status=%d, want 404", rec.Code)
|
||||
}
|
||||
// 7. Content zips stay read-only — even for an admin.
|
||||
if rec := do(http.MethodPut, "/Proj/Foo.zip/m.txt", "alice@x", true, []byte("y")); rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("content-zip PUT: status=%d, want 405", rec.Code)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
// AppAvailableAt reports whether app's virtual HTML can be served at
|
||||
// requestDir. Delegates to the .zddc cascade's available_tools union
|
||||
// (zddc.IsToolAvailableAt). The convention previously hardcoded here
|
||||
// now lives in defaults.zddc.yaml and is overridable per-directory
|
||||
// now lives in internal/zddc/defaults/ and is overridable per-directory
|
||||
// by operators.
|
||||
//
|
||||
// Operators can always drop a real <name>.html file at any path to
|
||||
|
|
@ -74,7 +74,7 @@ func AppAvailableAt(root, requestDir, app string) bool {
|
|||
// Phase 3b: delegates to zddc.DefaultToolAt, which resolves the
|
||||
// answer from the .zddc cascade (operator on-disk + embedded
|
||||
// defaults). The convention previously hardcoded in the switch
|
||||
// statement below now lives in zddc/internal/zddc/defaults.zddc.yaml
|
||||
// statement below now lives in zddc/internal/zddc/defaults/
|
||||
// and is overridable per-directory by operators.
|
||||
//
|
||||
// Project root itself (depth-1) still returns "" — the cascade
|
||||
|
|
|
|||
|
|
@ -74,6 +74,20 @@
|
|||
/* Shape */
|
||||
--radius: 4px;
|
||||
|
||||
/* Spacing scale — referenced by the tables tool (tables/css/table.css).
|
||||
Were undefined (var() with no fallback → collapsed to 0), which left
|
||||
table cells unpadded and the table flush to the viewport edges. */
|
||||
--spacing-sm: 0.4rem;
|
||||
--spacing-md: 0.8rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
|
||||
/* Token aliases the tables tool references under --color-*/--radius-*
|
||||
names; map them to the canonical tokens (themed values flow through). */
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-border: var(--border);
|
||||
--color-bg-elevated: var(--bg-secondary);
|
||||
--radius-sm: var(--radius);
|
||||
|
||||
/* Typography. --font-display covers headings (Source Serif 4 — a refined
|
||||
transitional serif that reads as "engineering / document / serious"
|
||||
without being academic). --font is body UI text (IBM Plex Sans —
|
||||
|
|
@ -855,53 +869,10 @@ body.help-open .app-header {
|
|||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* 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/elevation.css — page-wide armed chrome for admin mode.
|
||||
The elevate CONTROL is the "Admin mode" item in the shared profile menu
|
||||
(shared/profile-menu.{js,css}); this file only styles the unmistakable
|
||||
"you are elevated" cues: the red viewport frame + the sticky banner. */
|
||||
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
easy to miss; these add an inescapable visual cue:
|
||||
|
|
@ -978,6 +949,118 @@ body.is-elevated::after {
|
|||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* shared/profile-menu.css — header account menu (upper-right).
|
||||
shared/profile-menu.js mounts a button into `.header-right` and toggles
|
||||
a dropdown with the signed-in email, Admin mode, Profile, Access tokens,
|
||||
and Sign out. Server mode only. */
|
||||
|
||||
.profile-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* The button: a small circular avatar showing the email initial. */
|
||||
.profile-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
}
|
||||
.profile-btn__avatar {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Armed (admin mode on): a red ring so the elevated state reads from the
|
||||
button even when the menu is closed — pairs with the page banner/frame. */
|
||||
.profile-btn--armed {
|
||||
box-shadow: 0 0 0 2px var(--danger, #dc3545);
|
||||
border-color: var(--danger, #dc3545);
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__panel {
|
||||
display: none;
|
||||
/* Fixed + JS-positioned from the button rect: an absolute panel gets
|
||||
trapped below the content layer by the app's stacking contexts, so
|
||||
anchor it to the viewport instead (profile-menu.js sets top/right). */
|
||||
position: fixed;
|
||||
min-width: 15rem;
|
||||
z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */
|
||||
background: var(--bg, #fff);
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: var(--radius, 6px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
|
||||
padding: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.profile-menu__panel.open { display: block; }
|
||||
|
||||
.profile-menu__id {
|
||||
padding: 0.35rem 0.55rem 0.45rem;
|
||||
}
|
||||
.profile-menu__email {
|
||||
font-weight: 600;
|
||||
color: var(--text, #222);
|
||||
word-break: break-all;
|
||||
}
|
||||
.profile-menu__role {
|
||||
margin-top: 0.1rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__sep {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0;
|
||||
background: var(--border, #eee);
|
||||
}
|
||||
|
||||
.profile-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: var(--radius, 4px);
|
||||
color: var(--text, #222);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.profile-menu__item:hover {
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.profile-menu__toggle { cursor: pointer; }
|
||||
.profile-menu__check {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger, #dc3545);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.profile-menu__toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.profile-menu__hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||
inherits the logo's box and adds a subtle hover/focus affordance
|
||||
so it reads as clickable without altering the logo's visual weight. */
|
||||
|
|
@ -2582,18 +2665,12 @@ td[data-field="trackingNumber"] {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||
</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">?</button>
|
||||
</div>
|
||||
|
|
@ -10855,26 +10932,31 @@ window.app.modules.filtering = {
|
|||
}
|
||||
}());
|
||||
|
||||
// shared/elevation.js — admin elevation via URL toggle.
|
||||
// shared/elevation.js — admin elevation state machine.
|
||||
//
|
||||
// Sudo-style model: admins behave as normal users by default; elevating
|
||||
// the session turns on admin escape hatches (WORM bypass, .zddc edit
|
||||
// authority, profile admin scaffolds). State is carried in a
|
||||
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
|
||||
// → zddc.Principal{Elevated}.
|
||||
// the session turns on admin escape hatches (WORM bypass, recursive
|
||||
// delete, rearranging records, profile admin scaffolds — NOT config-edit,
|
||||
// which is standing). State is carried in a `zddc-elevate=1` cookie that
|
||||
// the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
|
||||
//
|
||||
// Toggle is by URL query param — `?admin=true` to arm, `?admin=false`
|
||||
// (or the red banner's "Drop admin" button) to drop — so it's reachable
|
||||
// from ANY zddc-server page, not just ones that render a header control.
|
||||
// The cookie is the sticky state: it persists across navigation for its
|
||||
// Max-Age window, so the param need not stay in the URL (we strip it).
|
||||
// Arming is gated on /.profile/access `can_elevate`, so only real admins
|
||||
// can set it; a non-admin's ?admin=true is a silent no-op.
|
||||
// This module owns the STATE (cookie, armed chrome/banner, ephemeral
|
||||
// lifecycle, the change event) + exposes setOn/setOff/isElevated. The
|
||||
// on-page elevate CONTROL lives in the shared profile menu
|
||||
// (shared/profile-menu.js) — an "Admin mode" item shown only to
|
||||
// can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
|
||||
// into any URL is also honoured (gated on can_elevate), for deep links /
|
||||
// scripting.
|
||||
//
|
||||
// Applying the cookie reloads to the cleaned URL so the server re-renders
|
||||
// under the new state (admin scaffolds in some tool HTML are server-
|
||||
// rendered, so a client-only flip wouldn't reach them). The red viewport
|
||||
// border + banner (applyArmedChrome) reflect the cookie on every load.
|
||||
// Admin mode is EPHEMERAL — scoped to the page you turned it on:
|
||||
// * the cookie is a SESSION cookie (no Max-Age), and
|
||||
// * we clear it on `pagehide`, so navigating away / closing the tab
|
||||
// drops admin (you re-arm deliberately on the next page).
|
||||
// Because of that we apply state IN PLACE (no reload — a reload's pagehide
|
||||
// would race the clear). SPAs that server-render elevation-dependent data
|
||||
// (e.g. browse's listing verbs) listen for the `zddc:elevationchange`
|
||||
// event we emit and re-fetch. The red viewport border + banner
|
||||
// (applyArmedChrome) reflect the cookie, kept in sync on every change.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
|
|
@ -10895,16 +10977,43 @@ window.app.modules.filtering = {
|
|||
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';
|
||||
// shapes. No Max-Age → a SESSION cookie: it dies with the tab
|
||||
// and, combined with the pagehide handler below, is cleared the
|
||||
// moment you leave the page. Admin powers never silently
|
||||
// outlive the page you armed them on.
|
||||
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
|
||||
} else {
|
||||
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
|
||||
}
|
||||
}
|
||||
|
||||
// emitChange notifies same-page listeners (SPAs that server-render
|
||||
// elevation-dependent data, e.g. browse's listing verbs / editor
|
||||
// affordances) so they can re-fetch without a full reload.
|
||||
function emitChange() {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('zddc:elevationchange', {
|
||||
detail: { elevated: isElevated() }
|
||||
}));
|
||||
} catch (_e) { /* CustomEvent unsupported — non-fatal */ }
|
||||
}
|
||||
|
||||
// setOn / setOff are the single funnel for every arm/drop path (the
|
||||
// profile menu's Admin mode item, the ?admin= URL param, the banner's
|
||||
// Drop button). Each flips the cookie, re-paints the armed chrome, and
|
||||
// emits the change — no reload. The profile menu listens for the change
|
||||
// event to keep its checkbox + armed indicator in sync.
|
||||
function setOn() {
|
||||
setElevated(true);
|
||||
applyArmedChrome(true);
|
||||
emitChange();
|
||||
}
|
||||
function setOff() {
|
||||
setElevated(false);
|
||||
applyArmedChrome(false);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var resp = await fetch('/.profile/access', {
|
||||
|
|
@ -10950,34 +11059,26 @@ window.app.modules.filtering = {
|
|||
return u.pathname + (qs ? '?' + qs : '') + u.hash;
|
||||
}
|
||||
|
||||
// handleAdminParam applies a ?admin= request. Returns true when a
|
||||
// navigation (reload) is underway so the caller can stop. Enabling is
|
||||
// gated on can_elevate — a non-admin who types ?admin=true just gets
|
||||
// the param stripped, never a misleading red border. Disabling is open
|
||||
// (anyone may drop a cookie they somehow hold).
|
||||
async function handleAdminParam() {
|
||||
// handleAdminParam applies a ?admin= request IN PLACE (no reload — see
|
||||
// the module header on why reloads would race the pagehide-clear).
|
||||
// Enabling is gated on can_elevate — a non-admin who types ?admin=true
|
||||
// just gets the param stripped, never a misleading red border.
|
||||
// Disabling is open (anyone may drop a cookie they somehow hold).
|
||||
// `access` (a prefetched /.profile/access, may be null) lets init reuse
|
||||
// its single fetch instead of issuing a second one.
|
||||
async function handleAdminParam(access) {
|
||||
var want = adminParam();
|
||||
if (want === null) return false;
|
||||
if (want === null) return;
|
||||
var clean = urlWithoutAdmin();
|
||||
if (want === isElevated()) {
|
||||
// Already in the requested state — just clean the URL, no reload.
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
return false;
|
||||
}
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
if (want === isElevated()) return; // already in the requested state
|
||||
if (want === true) {
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.can_elevate) {
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
return false;
|
||||
}
|
||||
setElevated(true);
|
||||
if (access === undefined) access = await fetchAccess();
|
||||
if (!access || !access.can_elevate) return; // silent no-op
|
||||
setOn();
|
||||
} else {
|
||||
setElevated(false);
|
||||
setOff();
|
||||
}
|
||||
// Navigate to the clean URL (a real load, so the server re-renders
|
||||
// under the new cookie) and replace history so Back is safe.
|
||||
window.location.replace(clean);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Page-wide affordances when elevation is active. The toggle alone
|
||||
|
|
@ -11008,10 +11109,7 @@ window.app.modules.filtering = {
|
|||
+ '</button>';
|
||||
document.body.insertBefore(banner, document.body.firstChild);
|
||||
var off = banner.querySelector('#elevation-banner-off');
|
||||
if (off) off.addEventListener('click', function () {
|
||||
setElevated(false);
|
||||
window.location.reload();
|
||||
});
|
||||
if (off) off.addEventListener('click', function () { setOff(); });
|
||||
}
|
||||
} else if (banner) {
|
||||
banner.parentNode.removeChild(banner);
|
||||
|
|
@ -11019,16 +11117,30 @@ window.app.modules.filtering = {
|
|||
}
|
||||
|
||||
async function init() {
|
||||
// Apply (or tear down) the red border + banner from the cookie on
|
||||
// every page load — admin mode is toggled by URL, but the armed
|
||||
// chrome must surface everywhere so the user can't accidentally
|
||||
// write through an elevated context on a page they didn't toggle.
|
||||
// file:// (offline FS-Access mode) has no server to elevate against.
|
||||
if (window.location.protocol === 'file:') return;
|
||||
|
||||
// Reflect the cookie's armed chrome on every load (a leftover from a
|
||||
// not-yet-fired pagehide, or an arrived-with ?admin link).
|
||||
applyArmedChrome(isElevated());
|
||||
|
||||
// Honour ?admin=true|false typed into any zddc-server URL. There's
|
||||
// no on-screen toggle anymore — the URL is the enable path and the
|
||||
// red banner's "Drop admin" button is the one-click disable.
|
||||
// Honour ?admin=true|false typed into any URL — handleAdminParam
|
||||
// fetches /.profile/access itself to gate arming on can_elevate. The
|
||||
// on-page elevate control lives in the shared profile menu
|
||||
// (shared/profile-menu.js), which calls setOn/setOff and listens for
|
||||
// zddc:elevationchange to keep its checkbox + armed ring in sync.
|
||||
await handleAdminParam();
|
||||
|
||||
// Admin mode is per-page: clear the cookie when the page goes away so
|
||||
// it never persists past a navigation.
|
||||
window.addEventListener('pagehide', function () {
|
||||
if (isElevated()) setElevated(false);
|
||||
});
|
||||
// bfcache can restore a page whose pagehide already cleared the
|
||||
// cookie — re-sync the armed chrome so chrome ≠ cookie can't happen.
|
||||
window.addEventListener('pageshow', function (e) {
|
||||
if (e.persisted) applyArmedChrome(isElevated());
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
|
@ -11037,7 +11149,178 @@ window.app.modules.filtering = {
|
|||
init();
|
||||
}
|
||||
|
||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||
window.zddc.elevation = {
|
||||
isElevated: isElevated,
|
||||
setElevated: setElevated,
|
||||
setOn: setOn,
|
||||
setOff: setOff
|
||||
};
|
||||
})();
|
||||
|
||||
// shared/profile-menu.js — account menu in the header's upper-right.
|
||||
//
|
||||
// Replaces the old floating elevation toggle. Admin mode is now one item in
|
||||
// this dropdown, alongside the signed-in email, Profile, and Access tokens.
|
||||
// Mounts into the tool header's `.header-right` cluster (every tool ships one)
|
||||
// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome
|
||||
// / ephemeral state machine stays in shared/elevation.js.
|
||||
//
|
||||
// No logout: authentication is the upstream proxy's concern (oauth2-proxy /
|
||||
// Authelia) — ZDDC owns no session, so it doesn't touch sign-out.
|
||||
//
|
||||
// Server mode only: it reads /.profile/access for the email + can_elevate.
|
||||
// On file:// (offline FS-Access mode) there's no server account, so nothing
|
||||
// renders.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.zddc) window.zddc = {};
|
||||
if (window.zddc.profileMenu) return;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var r = await fetch('/.profile/access', {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (_e) { return null; }
|
||||
}
|
||||
|
||||
var elevation = null;
|
||||
var panelEl = null, btnEl = null, adminInput = null;
|
||||
|
||||
function isElevated() {
|
||||
return !!(elevation && elevation.isElevated && elevation.isElevated());
|
||||
}
|
||||
|
||||
// Keep the button's armed ring + the menu checkbox in lockstep with the
|
||||
// elevation cookie (flipped here, by ?admin=, or by the banner's Drop).
|
||||
function syncArmed() {
|
||||
if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated());
|
||||
if (adminInput) adminInput.checked = isElevated();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (panelEl) panelEl.classList.remove('open');
|
||||
if (btnEl) btnEl.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
// The panel is position:fixed (to escape the app's stacking contexts), so
|
||||
// anchor it to the button rect — top just below it, right-aligned.
|
||||
function positionPanel() {
|
||||
var r = btnEl.getBoundingClientRect();
|
||||
panelEl.style.top = (r.bottom + 4) + 'px';
|
||||
panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
|
||||
panelEl.style.left = 'auto';
|
||||
}
|
||||
function toggleMenu() {
|
||||
if (!panelEl) return;
|
||||
var open = panelEl.classList.toggle('open');
|
||||
if (open) positionPanel();
|
||||
btnEl.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function linkItem(text, href) {
|
||||
var a = el('a', 'profile-menu__item', text);
|
||||
a.href = href;
|
||||
a.setAttribute('role', 'menuitem');
|
||||
return a;
|
||||
}
|
||||
|
||||
function build(access) {
|
||||
var wrap = el('div', 'profile-menu');
|
||||
|
||||
btnEl = el('button', 'btn btn-secondary profile-btn');
|
||||
btnEl.type = 'button';
|
||||
btnEl.id = 'profile-btn';
|
||||
btnEl.title = 'Account: ' + (access.email || 'signed in');
|
||||
btnEl.setAttribute('aria-haspopup', 'menu');
|
||||
btnEl.setAttribute('aria-expanded', 'false');
|
||||
var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase();
|
||||
btnEl.appendChild(el('span', 'profile-btn__avatar', initial));
|
||||
btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); });
|
||||
wrap.appendChild(btnEl);
|
||||
|
||||
panelEl = el('div', 'profile-menu__panel');
|
||||
panelEl.setAttribute('role', 'menu');
|
||||
|
||||
var id = el('div', 'profile-menu__id');
|
||||
id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)'));
|
||||
if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin'));
|
||||
else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin'));
|
||||
panelEl.appendChild(id);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
|
||||
// Admin mode — only offered to principals who actually have admin
|
||||
// authority somewhere (can_elevate). Drops automatically on leave.
|
||||
if (access.can_elevate && elevation) {
|
||||
var row = el('label', 'profile-menu__item profile-menu__toggle');
|
||||
adminInput = document.createElement('input');
|
||||
adminInput.type = 'checkbox';
|
||||
adminInput.className = 'profile-menu__check';
|
||||
adminInput.checked = isElevated();
|
||||
adminInput.addEventListener('change', function () {
|
||||
if (adminInput.checked) elevation.setOn(); else elevation.setOff();
|
||||
});
|
||||
row.appendChild(adminInput);
|
||||
var txt = el('span', 'profile-menu__toggle-label');
|
||||
txt.appendChild(el('span', null, 'Admin mode'));
|
||||
txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page'));
|
||||
row.appendChild(txt);
|
||||
panelEl.appendChild(row);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
}
|
||||
|
||||
panelEl.appendChild(linkItem('Profile', '/.profile'));
|
||||
panelEl.appendChild(linkItem('Access tokens', '/.tokens'));
|
||||
// No "Sign out": authentication is the upstream proxy's concern
|
||||
// (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it
|
||||
// doesn't render a logout affordance.
|
||||
|
||||
// Portal the panel to <body>, not inside the header: the app's
|
||||
// layout creates stacking contexts that trap even a fixed+high
|
||||
// z-index panel below the content. As a direct body child it sits in
|
||||
// the root stacking context and reliably overlays everything.
|
||||
// position:fixed + positionPanel() keep it anchored to the button.
|
||||
document.body.appendChild(panelEl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (window.location.protocol === 'file:') return;
|
||||
elevation = window.zddc.elevation || null;
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.email) return; // unauthenticated / non-zddc backend
|
||||
var host = document.querySelector('.header-right');
|
||||
if (!host) return;
|
||||
|
||||
host.appendChild(build(access));
|
||||
syncArmed();
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (panelEl && panelEl.classList.contains('open')
|
||||
&& !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
|
||||
window.addEventListener('zddc:elevationchange', syncArmed);
|
||||
|
||||
window.zddc.profileMenu = { close: closeMenu };
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
||||
// shared/cap.js — client-side capability helpers for permission gating.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -74,6 +74,20 @@
|
|||
/* Shape */
|
||||
--radius: 4px;
|
||||
|
||||
/* Spacing scale — referenced by the tables tool (tables/css/table.css).
|
||||
Were undefined (var() with no fallback → collapsed to 0), which left
|
||||
table cells unpadded and the table flush to the viewport edges. */
|
||||
--spacing-sm: 0.4rem;
|
||||
--spacing-md: 0.8rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
|
||||
/* Token aliases the tables tool references under --color-*/--radius-*
|
||||
names; map them to the canonical tokens (themed values flow through). */
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-border: var(--border);
|
||||
--color-bg-elevated: var(--bg-secondary);
|
||||
--radius-sm: var(--radius);
|
||||
|
||||
/* Typography. --font-display covers headings (Source Serif 4 — a refined
|
||||
transitional serif that reads as "engineering / document / serious"
|
||||
without being academic). --font is body UI text (IBM Plex Sans —
|
||||
|
|
@ -855,53 +869,10 @@ body.help-open .app-header {
|
|||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* 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/elevation.css — page-wide armed chrome for admin mode.
|
||||
The elevate CONTROL is the "Admin mode" item in the shared profile menu
|
||||
(shared/profile-menu.{js,css}); this file only styles the unmistakable
|
||||
"you are elevated" cues: the red viewport frame + the sticky banner. */
|
||||
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
easy to miss; these add an inescapable visual cue:
|
||||
|
|
@ -978,6 +949,118 @@ body.is-elevated::after {
|
|||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* shared/profile-menu.css — header account menu (upper-right).
|
||||
shared/profile-menu.js mounts a button into `.header-right` and toggles
|
||||
a dropdown with the signed-in email, Admin mode, Profile, Access tokens,
|
||||
and Sign out. Server mode only. */
|
||||
|
||||
.profile-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* The button: a small circular avatar showing the email initial. */
|
||||
.profile-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
}
|
||||
.profile-btn__avatar {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Armed (admin mode on): a red ring so the elevated state reads from the
|
||||
button even when the menu is closed — pairs with the page banner/frame. */
|
||||
.profile-btn--armed {
|
||||
box-shadow: 0 0 0 2px var(--danger, #dc3545);
|
||||
border-color: var(--danger, #dc3545);
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__panel {
|
||||
display: none;
|
||||
/* Fixed + JS-positioned from the button rect: an absolute panel gets
|
||||
trapped below the content layer by the app's stacking contexts, so
|
||||
anchor it to the viewport instead (profile-menu.js sets top/right). */
|
||||
position: fixed;
|
||||
min-width: 15rem;
|
||||
z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */
|
||||
background: var(--bg, #fff);
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: var(--radius, 6px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
|
||||
padding: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.profile-menu__panel.open { display: block; }
|
||||
|
||||
.profile-menu__id {
|
||||
padding: 0.35rem 0.55rem 0.45rem;
|
||||
}
|
||||
.profile-menu__email {
|
||||
font-weight: 600;
|
||||
color: var(--text, #222);
|
||||
word-break: break-all;
|
||||
}
|
||||
.profile-menu__role {
|
||||
margin-top: 0.1rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__sep {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0;
|
||||
background: var(--border, #eee);
|
||||
}
|
||||
|
||||
.profile-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: var(--radius, 4px);
|
||||
color: var(--text, #222);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.profile-menu__item:hover {
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.profile-menu__toggle { cursor: pointer; }
|
||||
.profile-menu__check {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger, #dc3545);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.profile-menu__toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.profile-menu__hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||
inherits the logo's box and adds a subtle hover/focus affordance
|
||||
so it reads as clickable without altering the logo's visual weight. */
|
||||
|
|
@ -1793,18 +1876,12 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Classifier</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||
</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">?</button>
|
||||
</div>
|
||||
|
|
@ -9979,26 +10056,31 @@ X.B(E,Y);return E}return J}())
|
|||
}
|
||||
}());
|
||||
|
||||
// shared/elevation.js — admin elevation via URL toggle.
|
||||
// shared/elevation.js — admin elevation state machine.
|
||||
//
|
||||
// Sudo-style model: admins behave as normal users by default; elevating
|
||||
// the session turns on admin escape hatches (WORM bypass, .zddc edit
|
||||
// authority, profile admin scaffolds). State is carried in a
|
||||
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
|
||||
// → zddc.Principal{Elevated}.
|
||||
// the session turns on admin escape hatches (WORM bypass, recursive
|
||||
// delete, rearranging records, profile admin scaffolds — NOT config-edit,
|
||||
// which is standing). State is carried in a `zddc-elevate=1` cookie that
|
||||
// the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
|
||||
//
|
||||
// Toggle is by URL query param — `?admin=true` to arm, `?admin=false`
|
||||
// (or the red banner's "Drop admin" button) to drop — so it's reachable
|
||||
// from ANY zddc-server page, not just ones that render a header control.
|
||||
// The cookie is the sticky state: it persists across navigation for its
|
||||
// Max-Age window, so the param need not stay in the URL (we strip it).
|
||||
// Arming is gated on /.profile/access `can_elevate`, so only real admins
|
||||
// can set it; a non-admin's ?admin=true is a silent no-op.
|
||||
// This module owns the STATE (cookie, armed chrome/banner, ephemeral
|
||||
// lifecycle, the change event) + exposes setOn/setOff/isElevated. The
|
||||
// on-page elevate CONTROL lives in the shared profile menu
|
||||
// (shared/profile-menu.js) — an "Admin mode" item shown only to
|
||||
// can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
|
||||
// into any URL is also honoured (gated on can_elevate), for deep links /
|
||||
// scripting.
|
||||
//
|
||||
// Applying the cookie reloads to the cleaned URL so the server re-renders
|
||||
// under the new state (admin scaffolds in some tool HTML are server-
|
||||
// rendered, so a client-only flip wouldn't reach them). The red viewport
|
||||
// border + banner (applyArmedChrome) reflect the cookie on every load.
|
||||
// Admin mode is EPHEMERAL — scoped to the page you turned it on:
|
||||
// * the cookie is a SESSION cookie (no Max-Age), and
|
||||
// * we clear it on `pagehide`, so navigating away / closing the tab
|
||||
// drops admin (you re-arm deliberately on the next page).
|
||||
// Because of that we apply state IN PLACE (no reload — a reload's pagehide
|
||||
// would race the clear). SPAs that server-render elevation-dependent data
|
||||
// (e.g. browse's listing verbs) listen for the `zddc:elevationchange`
|
||||
// event we emit and re-fetch. The red viewport border + banner
|
||||
// (applyArmedChrome) reflect the cookie, kept in sync on every change.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
|
|
@ -10019,16 +10101,43 @@ X.B(E,Y);return E}return J}())
|
|||
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';
|
||||
// shapes. No Max-Age → a SESSION cookie: it dies with the tab
|
||||
// and, combined with the pagehide handler below, is cleared the
|
||||
// moment you leave the page. Admin powers never silently
|
||||
// outlive the page you armed them on.
|
||||
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
|
||||
} else {
|
||||
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
|
||||
}
|
||||
}
|
||||
|
||||
// emitChange notifies same-page listeners (SPAs that server-render
|
||||
// elevation-dependent data, e.g. browse's listing verbs / editor
|
||||
// affordances) so they can re-fetch without a full reload.
|
||||
function emitChange() {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('zddc:elevationchange', {
|
||||
detail: { elevated: isElevated() }
|
||||
}));
|
||||
} catch (_e) { /* CustomEvent unsupported — non-fatal */ }
|
||||
}
|
||||
|
||||
// setOn / setOff are the single funnel for every arm/drop path (the
|
||||
// profile menu's Admin mode item, the ?admin= URL param, the banner's
|
||||
// Drop button). Each flips the cookie, re-paints the armed chrome, and
|
||||
// emits the change — no reload. The profile menu listens for the change
|
||||
// event to keep its checkbox + armed indicator in sync.
|
||||
function setOn() {
|
||||
setElevated(true);
|
||||
applyArmedChrome(true);
|
||||
emitChange();
|
||||
}
|
||||
function setOff() {
|
||||
setElevated(false);
|
||||
applyArmedChrome(false);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var resp = await fetch('/.profile/access', {
|
||||
|
|
@ -10074,34 +10183,26 @@ X.B(E,Y);return E}return J}())
|
|||
return u.pathname + (qs ? '?' + qs : '') + u.hash;
|
||||
}
|
||||
|
||||
// handleAdminParam applies a ?admin= request. Returns true when a
|
||||
// navigation (reload) is underway so the caller can stop. Enabling is
|
||||
// gated on can_elevate — a non-admin who types ?admin=true just gets
|
||||
// the param stripped, never a misleading red border. Disabling is open
|
||||
// (anyone may drop a cookie they somehow hold).
|
||||
async function handleAdminParam() {
|
||||
// handleAdminParam applies a ?admin= request IN PLACE (no reload — see
|
||||
// the module header on why reloads would race the pagehide-clear).
|
||||
// Enabling is gated on can_elevate — a non-admin who types ?admin=true
|
||||
// just gets the param stripped, never a misleading red border.
|
||||
// Disabling is open (anyone may drop a cookie they somehow hold).
|
||||
// `access` (a prefetched /.profile/access, may be null) lets init reuse
|
||||
// its single fetch instead of issuing a second one.
|
||||
async function handleAdminParam(access) {
|
||||
var want = adminParam();
|
||||
if (want === null) return false;
|
||||
if (want === null) return;
|
||||
var clean = urlWithoutAdmin();
|
||||
if (want === isElevated()) {
|
||||
// Already in the requested state — just clean the URL, no reload.
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
return false;
|
||||
}
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
if (want === isElevated()) return; // already in the requested state
|
||||
if (want === true) {
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.can_elevate) {
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
return false;
|
||||
}
|
||||
setElevated(true);
|
||||
if (access === undefined) access = await fetchAccess();
|
||||
if (!access || !access.can_elevate) return; // silent no-op
|
||||
setOn();
|
||||
} else {
|
||||
setElevated(false);
|
||||
setOff();
|
||||
}
|
||||
// Navigate to the clean URL (a real load, so the server re-renders
|
||||
// under the new cookie) and replace history so Back is safe.
|
||||
window.location.replace(clean);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Page-wide affordances when elevation is active. The toggle alone
|
||||
|
|
@ -10132,10 +10233,7 @@ X.B(E,Y);return E}return J}())
|
|||
+ '</button>';
|
||||
document.body.insertBefore(banner, document.body.firstChild);
|
||||
var off = banner.querySelector('#elevation-banner-off');
|
||||
if (off) off.addEventListener('click', function () {
|
||||
setElevated(false);
|
||||
window.location.reload();
|
||||
});
|
||||
if (off) off.addEventListener('click', function () { setOff(); });
|
||||
}
|
||||
} else if (banner) {
|
||||
banner.parentNode.removeChild(banner);
|
||||
|
|
@ -10143,16 +10241,30 @@ X.B(E,Y);return E}return J}())
|
|||
}
|
||||
|
||||
async function init() {
|
||||
// Apply (or tear down) the red border + banner from the cookie on
|
||||
// every page load — admin mode is toggled by URL, but the armed
|
||||
// chrome must surface everywhere so the user can't accidentally
|
||||
// write through an elevated context on a page they didn't toggle.
|
||||
// file:// (offline FS-Access mode) has no server to elevate against.
|
||||
if (window.location.protocol === 'file:') return;
|
||||
|
||||
// Reflect the cookie's armed chrome on every load (a leftover from a
|
||||
// not-yet-fired pagehide, or an arrived-with ?admin link).
|
||||
applyArmedChrome(isElevated());
|
||||
|
||||
// Honour ?admin=true|false typed into any zddc-server URL. There's
|
||||
// no on-screen toggle anymore — the URL is the enable path and the
|
||||
// red banner's "Drop admin" button is the one-click disable.
|
||||
// Honour ?admin=true|false typed into any URL — handleAdminParam
|
||||
// fetches /.profile/access itself to gate arming on can_elevate. The
|
||||
// on-page elevate control lives in the shared profile menu
|
||||
// (shared/profile-menu.js), which calls setOn/setOff and listens for
|
||||
// zddc:elevationchange to keep its checkbox + armed ring in sync.
|
||||
await handleAdminParam();
|
||||
|
||||
// Admin mode is per-page: clear the cookie when the page goes away so
|
||||
// it never persists past a navigation.
|
||||
window.addEventListener('pagehide', function () {
|
||||
if (isElevated()) setElevated(false);
|
||||
});
|
||||
// bfcache can restore a page whose pagehide already cleared the
|
||||
// cookie — re-sync the armed chrome so chrome ≠ cookie can't happen.
|
||||
window.addEventListener('pageshow', function (e) {
|
||||
if (e.persisted) applyArmedChrome(isElevated());
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
|
@ -10161,7 +10273,178 @@ X.B(E,Y);return E}return J}())
|
|||
init();
|
||||
}
|
||||
|
||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||
window.zddc.elevation = {
|
||||
isElevated: isElevated,
|
||||
setElevated: setElevated,
|
||||
setOn: setOn,
|
||||
setOff: setOff
|
||||
};
|
||||
})();
|
||||
|
||||
// shared/profile-menu.js — account menu in the header's upper-right.
|
||||
//
|
||||
// Replaces the old floating elevation toggle. Admin mode is now one item in
|
||||
// this dropdown, alongside the signed-in email, Profile, and Access tokens.
|
||||
// Mounts into the tool header's `.header-right` cluster (every tool ships one)
|
||||
// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome
|
||||
// / ephemeral state machine stays in shared/elevation.js.
|
||||
//
|
||||
// No logout: authentication is the upstream proxy's concern (oauth2-proxy /
|
||||
// Authelia) — ZDDC owns no session, so it doesn't touch sign-out.
|
||||
//
|
||||
// Server mode only: it reads /.profile/access for the email + can_elevate.
|
||||
// On file:// (offline FS-Access mode) there's no server account, so nothing
|
||||
// renders.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.zddc) window.zddc = {};
|
||||
if (window.zddc.profileMenu) return;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var r = await fetch('/.profile/access', {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (_e) { return null; }
|
||||
}
|
||||
|
||||
var elevation = null;
|
||||
var panelEl = null, btnEl = null, adminInput = null;
|
||||
|
||||
function isElevated() {
|
||||
return !!(elevation && elevation.isElevated && elevation.isElevated());
|
||||
}
|
||||
|
||||
// Keep the button's armed ring + the menu checkbox in lockstep with the
|
||||
// elevation cookie (flipped here, by ?admin=, or by the banner's Drop).
|
||||
function syncArmed() {
|
||||
if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated());
|
||||
if (adminInput) adminInput.checked = isElevated();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (panelEl) panelEl.classList.remove('open');
|
||||
if (btnEl) btnEl.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
// The panel is position:fixed (to escape the app's stacking contexts), so
|
||||
// anchor it to the button rect — top just below it, right-aligned.
|
||||
function positionPanel() {
|
||||
var r = btnEl.getBoundingClientRect();
|
||||
panelEl.style.top = (r.bottom + 4) + 'px';
|
||||
panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
|
||||
panelEl.style.left = 'auto';
|
||||
}
|
||||
function toggleMenu() {
|
||||
if (!panelEl) return;
|
||||
var open = panelEl.classList.toggle('open');
|
||||
if (open) positionPanel();
|
||||
btnEl.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function linkItem(text, href) {
|
||||
var a = el('a', 'profile-menu__item', text);
|
||||
a.href = href;
|
||||
a.setAttribute('role', 'menuitem');
|
||||
return a;
|
||||
}
|
||||
|
||||
function build(access) {
|
||||
var wrap = el('div', 'profile-menu');
|
||||
|
||||
btnEl = el('button', 'btn btn-secondary profile-btn');
|
||||
btnEl.type = 'button';
|
||||
btnEl.id = 'profile-btn';
|
||||
btnEl.title = 'Account: ' + (access.email || 'signed in');
|
||||
btnEl.setAttribute('aria-haspopup', 'menu');
|
||||
btnEl.setAttribute('aria-expanded', 'false');
|
||||
var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase();
|
||||
btnEl.appendChild(el('span', 'profile-btn__avatar', initial));
|
||||
btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); });
|
||||
wrap.appendChild(btnEl);
|
||||
|
||||
panelEl = el('div', 'profile-menu__panel');
|
||||
panelEl.setAttribute('role', 'menu');
|
||||
|
||||
var id = el('div', 'profile-menu__id');
|
||||
id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)'));
|
||||
if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin'));
|
||||
else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin'));
|
||||
panelEl.appendChild(id);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
|
||||
// Admin mode — only offered to principals who actually have admin
|
||||
// authority somewhere (can_elevate). Drops automatically on leave.
|
||||
if (access.can_elevate && elevation) {
|
||||
var row = el('label', 'profile-menu__item profile-menu__toggle');
|
||||
adminInput = document.createElement('input');
|
||||
adminInput.type = 'checkbox';
|
||||
adminInput.className = 'profile-menu__check';
|
||||
adminInput.checked = isElevated();
|
||||
adminInput.addEventListener('change', function () {
|
||||
if (adminInput.checked) elevation.setOn(); else elevation.setOff();
|
||||
});
|
||||
row.appendChild(adminInput);
|
||||
var txt = el('span', 'profile-menu__toggle-label');
|
||||
txt.appendChild(el('span', null, 'Admin mode'));
|
||||
txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page'));
|
||||
row.appendChild(txt);
|
||||
panelEl.appendChild(row);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
}
|
||||
|
||||
panelEl.appendChild(linkItem('Profile', '/.profile'));
|
||||
panelEl.appendChild(linkItem('Access tokens', '/.tokens'));
|
||||
// No "Sign out": authentication is the upstream proxy's concern
|
||||
// (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it
|
||||
// doesn't render a logout affordance.
|
||||
|
||||
// Portal the panel to <body>, not inside the header: the app's
|
||||
// layout creates stacking contexts that trap even a fixed+high
|
||||
// z-index panel below the content. As a direct body child it sits in
|
||||
// the root stacking context and reliably overlays everything.
|
||||
// position:fixed + positionPanel() keep it anchored to the button.
|
||||
document.body.appendChild(panelEl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (window.location.protocol === 'file:') return;
|
||||
elevation = window.zddc.elevation || null;
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.email) return; // unauthenticated / non-zddc backend
|
||||
var host = document.querySelector('.header-right');
|
||||
if (!host) return;
|
||||
|
||||
host.appendChild(build(access));
|
||||
syncArmed();
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (panelEl && panelEl.classList.contains('open')
|
||||
&& !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
|
||||
window.addEventListener('zddc:elevationchange', syncArmed);
|
||||
|
||||
window.zddc.profileMenu = { close: closeMenu };
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
||||
// shared/cap.js — client-side capability helpers for permission gating.
|
||||
|
|
|
|||
|
|
@ -74,6 +74,20 @@
|
|||
/* Shape */
|
||||
--radius: 4px;
|
||||
|
||||
/* Spacing scale — referenced by the tables tool (tables/css/table.css).
|
||||
Were undefined (var() with no fallback → collapsed to 0), which left
|
||||
table cells unpadded and the table flush to the viewport edges. */
|
||||
--spacing-sm: 0.4rem;
|
||||
--spacing-md: 0.8rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
|
||||
/* Token aliases the tables tool references under --color-*/--radius-*
|
||||
names; map them to the canonical tokens (themed values flow through). */
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-border: var(--border);
|
||||
--color-bg-elevated: var(--bg-secondary);
|
||||
--radius-sm: var(--radius);
|
||||
|
||||
/* Typography. --font-display covers headings (Source Serif 4 — a refined
|
||||
transitional serif that reads as "engineering / document / serious"
|
||||
without being academic). --font is body UI text (IBM Plex Sans —
|
||||
|
|
@ -855,53 +869,10 @@ body.help-open .app-header {
|
|||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* 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/elevation.css — page-wide armed chrome for admin mode.
|
||||
The elevate CONTROL is the "Admin mode" item in the shared profile menu
|
||||
(shared/profile-menu.{js,css}); this file only styles the unmistakable
|
||||
"you are elevated" cues: the red viewport frame + the sticky banner. */
|
||||
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
easy to miss; these add an inescapable visual cue:
|
||||
|
|
@ -978,6 +949,118 @@ body.is-elevated::after {
|
|||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* shared/profile-menu.css — header account menu (upper-right).
|
||||
shared/profile-menu.js mounts a button into `.header-right` and toggles
|
||||
a dropdown with the signed-in email, Admin mode, Profile, Access tokens,
|
||||
and Sign out. Server mode only. */
|
||||
|
||||
.profile-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* The button: a small circular avatar showing the email initial. */
|
||||
.profile-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
}
|
||||
.profile-btn__avatar {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Armed (admin mode on): a red ring so the elevated state reads from the
|
||||
button even when the menu is closed — pairs with the page banner/frame. */
|
||||
.profile-btn--armed {
|
||||
box-shadow: 0 0 0 2px var(--danger, #dc3545);
|
||||
border-color: var(--danger, #dc3545);
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__panel {
|
||||
display: none;
|
||||
/* Fixed + JS-positioned from the button rect: an absolute panel gets
|
||||
trapped below the content layer by the app's stacking contexts, so
|
||||
anchor it to the viewport instead (profile-menu.js sets top/right). */
|
||||
position: fixed;
|
||||
min-width: 15rem;
|
||||
z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */
|
||||
background: var(--bg, #fff);
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: var(--radius, 6px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
|
||||
padding: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.profile-menu__panel.open { display: block; }
|
||||
|
||||
.profile-menu__id {
|
||||
padding: 0.35rem 0.55rem 0.45rem;
|
||||
}
|
||||
.profile-menu__email {
|
||||
font-weight: 600;
|
||||
color: var(--text, #222);
|
||||
word-break: break-all;
|
||||
}
|
||||
.profile-menu__role {
|
||||
margin-top: 0.1rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__sep {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0;
|
||||
background: var(--border, #eee);
|
||||
}
|
||||
|
||||
.profile-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: var(--radius, 4px);
|
||||
color: var(--text, #222);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.profile-menu__item:hover {
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.profile-menu__toggle { cursor: pointer; }
|
||||
.profile-menu__check {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger, #dc3545);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.profile-menu__toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.profile-menu__hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||
inherits the logo's box and adds a subtle hover/focus affordance
|
||||
so it reads as clickable without altering the logo's visual weight. */
|
||||
|
|
@ -1536,16 +1619,10 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3</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>
|
||||
|
|
@ -2546,26 +2623,31 @@ body {
|
|||
}
|
||||
}());
|
||||
|
||||
// shared/elevation.js — admin elevation via URL toggle.
|
||||
// shared/elevation.js — admin elevation state machine.
|
||||
//
|
||||
// Sudo-style model: admins behave as normal users by default; elevating
|
||||
// the session turns on admin escape hatches (WORM bypass, .zddc edit
|
||||
// authority, profile admin scaffolds). State is carried in a
|
||||
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
|
||||
// → zddc.Principal{Elevated}.
|
||||
// the session turns on admin escape hatches (WORM bypass, recursive
|
||||
// delete, rearranging records, profile admin scaffolds — NOT config-edit,
|
||||
// which is standing). State is carried in a `zddc-elevate=1` cookie that
|
||||
// the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
|
||||
//
|
||||
// Toggle is by URL query param — `?admin=true` to arm, `?admin=false`
|
||||
// (or the red banner's "Drop admin" button) to drop — so it's reachable
|
||||
// from ANY zddc-server page, not just ones that render a header control.
|
||||
// The cookie is the sticky state: it persists across navigation for its
|
||||
// Max-Age window, so the param need not stay in the URL (we strip it).
|
||||
// Arming is gated on /.profile/access `can_elevate`, so only real admins
|
||||
// can set it; a non-admin's ?admin=true is a silent no-op.
|
||||
// This module owns the STATE (cookie, armed chrome/banner, ephemeral
|
||||
// lifecycle, the change event) + exposes setOn/setOff/isElevated. The
|
||||
// on-page elevate CONTROL lives in the shared profile menu
|
||||
// (shared/profile-menu.js) — an "Admin mode" item shown only to
|
||||
// can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
|
||||
// into any URL is also honoured (gated on can_elevate), for deep links /
|
||||
// scripting.
|
||||
//
|
||||
// Applying the cookie reloads to the cleaned URL so the server re-renders
|
||||
// under the new state (admin scaffolds in some tool HTML are server-
|
||||
// rendered, so a client-only flip wouldn't reach them). The red viewport
|
||||
// border + banner (applyArmedChrome) reflect the cookie on every load.
|
||||
// Admin mode is EPHEMERAL — scoped to the page you turned it on:
|
||||
// * the cookie is a SESSION cookie (no Max-Age), and
|
||||
// * we clear it on `pagehide`, so navigating away / closing the tab
|
||||
// drops admin (you re-arm deliberately on the next page).
|
||||
// Because of that we apply state IN PLACE (no reload — a reload's pagehide
|
||||
// would race the clear). SPAs that server-render elevation-dependent data
|
||||
// (e.g. browse's listing verbs) listen for the `zddc:elevationchange`
|
||||
// event we emit and re-fetch. The red viewport border + banner
|
||||
// (applyArmedChrome) reflect the cookie, kept in sync on every change.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
|
|
@ -2586,16 +2668,43 @@ body {
|
|||
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';
|
||||
// shapes. No Max-Age → a SESSION cookie: it dies with the tab
|
||||
// and, combined with the pagehide handler below, is cleared the
|
||||
// moment you leave the page. Admin powers never silently
|
||||
// outlive the page you armed them on.
|
||||
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
|
||||
} else {
|
||||
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
|
||||
}
|
||||
}
|
||||
|
||||
// emitChange notifies same-page listeners (SPAs that server-render
|
||||
// elevation-dependent data, e.g. browse's listing verbs / editor
|
||||
// affordances) so they can re-fetch without a full reload.
|
||||
function emitChange() {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('zddc:elevationchange', {
|
||||
detail: { elevated: isElevated() }
|
||||
}));
|
||||
} catch (_e) { /* CustomEvent unsupported — non-fatal */ }
|
||||
}
|
||||
|
||||
// setOn / setOff are the single funnel for every arm/drop path (the
|
||||
// profile menu's Admin mode item, the ?admin= URL param, the banner's
|
||||
// Drop button). Each flips the cookie, re-paints the armed chrome, and
|
||||
// emits the change — no reload. The profile menu listens for the change
|
||||
// event to keep its checkbox + armed indicator in sync.
|
||||
function setOn() {
|
||||
setElevated(true);
|
||||
applyArmedChrome(true);
|
||||
emitChange();
|
||||
}
|
||||
function setOff() {
|
||||
setElevated(false);
|
||||
applyArmedChrome(false);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var resp = await fetch('/.profile/access', {
|
||||
|
|
@ -2641,34 +2750,26 @@ body {
|
|||
return u.pathname + (qs ? '?' + qs : '') + u.hash;
|
||||
}
|
||||
|
||||
// handleAdminParam applies a ?admin= request. Returns true when a
|
||||
// navigation (reload) is underway so the caller can stop. Enabling is
|
||||
// gated on can_elevate — a non-admin who types ?admin=true just gets
|
||||
// the param stripped, never a misleading red border. Disabling is open
|
||||
// (anyone may drop a cookie they somehow hold).
|
||||
async function handleAdminParam() {
|
||||
// handleAdminParam applies a ?admin= request IN PLACE (no reload — see
|
||||
// the module header on why reloads would race the pagehide-clear).
|
||||
// Enabling is gated on can_elevate — a non-admin who types ?admin=true
|
||||
// just gets the param stripped, never a misleading red border.
|
||||
// Disabling is open (anyone may drop a cookie they somehow hold).
|
||||
// `access` (a prefetched /.profile/access, may be null) lets init reuse
|
||||
// its single fetch instead of issuing a second one.
|
||||
async function handleAdminParam(access) {
|
||||
var want = adminParam();
|
||||
if (want === null) return false;
|
||||
if (want === null) return;
|
||||
var clean = urlWithoutAdmin();
|
||||
if (want === isElevated()) {
|
||||
// Already in the requested state — just clean the URL, no reload.
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
return false;
|
||||
}
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
if (want === isElevated()) return; // already in the requested state
|
||||
if (want === true) {
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.can_elevate) {
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
return false;
|
||||
}
|
||||
setElevated(true);
|
||||
if (access === undefined) access = await fetchAccess();
|
||||
if (!access || !access.can_elevate) return; // silent no-op
|
||||
setOn();
|
||||
} else {
|
||||
setElevated(false);
|
||||
setOff();
|
||||
}
|
||||
// Navigate to the clean URL (a real load, so the server re-renders
|
||||
// under the new cookie) and replace history so Back is safe.
|
||||
window.location.replace(clean);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Page-wide affordances when elevation is active. The toggle alone
|
||||
|
|
@ -2699,10 +2800,7 @@ body {
|
|||
+ '</button>';
|
||||
document.body.insertBefore(banner, document.body.firstChild);
|
||||
var off = banner.querySelector('#elevation-banner-off');
|
||||
if (off) off.addEventListener('click', function () {
|
||||
setElevated(false);
|
||||
window.location.reload();
|
||||
});
|
||||
if (off) off.addEventListener('click', function () { setOff(); });
|
||||
}
|
||||
} else if (banner) {
|
||||
banner.parentNode.removeChild(banner);
|
||||
|
|
@ -2710,16 +2808,30 @@ body {
|
|||
}
|
||||
|
||||
async function init() {
|
||||
// Apply (or tear down) the red border + banner from the cookie on
|
||||
// every page load — admin mode is toggled by URL, but the armed
|
||||
// chrome must surface everywhere so the user can't accidentally
|
||||
// write through an elevated context on a page they didn't toggle.
|
||||
// file:// (offline FS-Access mode) has no server to elevate against.
|
||||
if (window.location.protocol === 'file:') return;
|
||||
|
||||
// Reflect the cookie's armed chrome on every load (a leftover from a
|
||||
// not-yet-fired pagehide, or an arrived-with ?admin link).
|
||||
applyArmedChrome(isElevated());
|
||||
|
||||
// Honour ?admin=true|false typed into any zddc-server URL. There's
|
||||
// no on-screen toggle anymore — the URL is the enable path and the
|
||||
// red banner's "Drop admin" button is the one-click disable.
|
||||
// Honour ?admin=true|false typed into any URL — handleAdminParam
|
||||
// fetches /.profile/access itself to gate arming on can_elevate. The
|
||||
// on-page elevate control lives in the shared profile menu
|
||||
// (shared/profile-menu.js), which calls setOn/setOff and listens for
|
||||
// zddc:elevationchange to keep its checkbox + armed ring in sync.
|
||||
await handleAdminParam();
|
||||
|
||||
// Admin mode is per-page: clear the cookie when the page goes away so
|
||||
// it never persists past a navigation.
|
||||
window.addEventListener('pagehide', function () {
|
||||
if (isElevated()) setElevated(false);
|
||||
});
|
||||
// bfcache can restore a page whose pagehide already cleared the
|
||||
// cookie — re-sync the armed chrome so chrome ≠ cookie can't happen.
|
||||
window.addEventListener('pageshow', function (e) {
|
||||
if (e.persisted) applyArmedChrome(isElevated());
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
|
@ -2728,7 +2840,178 @@ body {
|
|||
init();
|
||||
}
|
||||
|
||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||
window.zddc.elevation = {
|
||||
isElevated: isElevated,
|
||||
setElevated: setElevated,
|
||||
setOn: setOn,
|
||||
setOff: setOff
|
||||
};
|
||||
})();
|
||||
|
||||
// shared/profile-menu.js — account menu in the header's upper-right.
|
||||
//
|
||||
// Replaces the old floating elevation toggle. Admin mode is now one item in
|
||||
// this dropdown, alongside the signed-in email, Profile, and Access tokens.
|
||||
// Mounts into the tool header's `.header-right` cluster (every tool ships one)
|
||||
// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome
|
||||
// / ephemeral state machine stays in shared/elevation.js.
|
||||
//
|
||||
// No logout: authentication is the upstream proxy's concern (oauth2-proxy /
|
||||
// Authelia) — ZDDC owns no session, so it doesn't touch sign-out.
|
||||
//
|
||||
// Server mode only: it reads /.profile/access for the email + can_elevate.
|
||||
// On file:// (offline FS-Access mode) there's no server account, so nothing
|
||||
// renders.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.zddc) window.zddc = {};
|
||||
if (window.zddc.profileMenu) return;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var r = await fetch('/.profile/access', {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (_e) { return null; }
|
||||
}
|
||||
|
||||
var elevation = null;
|
||||
var panelEl = null, btnEl = null, adminInput = null;
|
||||
|
||||
function isElevated() {
|
||||
return !!(elevation && elevation.isElevated && elevation.isElevated());
|
||||
}
|
||||
|
||||
// Keep the button's armed ring + the menu checkbox in lockstep with the
|
||||
// elevation cookie (flipped here, by ?admin=, or by the banner's Drop).
|
||||
function syncArmed() {
|
||||
if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated());
|
||||
if (adminInput) adminInput.checked = isElevated();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (panelEl) panelEl.classList.remove('open');
|
||||
if (btnEl) btnEl.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
// The panel is position:fixed (to escape the app's stacking contexts), so
|
||||
// anchor it to the button rect — top just below it, right-aligned.
|
||||
function positionPanel() {
|
||||
var r = btnEl.getBoundingClientRect();
|
||||
panelEl.style.top = (r.bottom + 4) + 'px';
|
||||
panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
|
||||
panelEl.style.left = 'auto';
|
||||
}
|
||||
function toggleMenu() {
|
||||
if (!panelEl) return;
|
||||
var open = panelEl.classList.toggle('open');
|
||||
if (open) positionPanel();
|
||||
btnEl.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function linkItem(text, href) {
|
||||
var a = el('a', 'profile-menu__item', text);
|
||||
a.href = href;
|
||||
a.setAttribute('role', 'menuitem');
|
||||
return a;
|
||||
}
|
||||
|
||||
function build(access) {
|
||||
var wrap = el('div', 'profile-menu');
|
||||
|
||||
btnEl = el('button', 'btn btn-secondary profile-btn');
|
||||
btnEl.type = 'button';
|
||||
btnEl.id = 'profile-btn';
|
||||
btnEl.title = 'Account: ' + (access.email || 'signed in');
|
||||
btnEl.setAttribute('aria-haspopup', 'menu');
|
||||
btnEl.setAttribute('aria-expanded', 'false');
|
||||
var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase();
|
||||
btnEl.appendChild(el('span', 'profile-btn__avatar', initial));
|
||||
btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); });
|
||||
wrap.appendChild(btnEl);
|
||||
|
||||
panelEl = el('div', 'profile-menu__panel');
|
||||
panelEl.setAttribute('role', 'menu');
|
||||
|
||||
var id = el('div', 'profile-menu__id');
|
||||
id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)'));
|
||||
if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin'));
|
||||
else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin'));
|
||||
panelEl.appendChild(id);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
|
||||
// Admin mode — only offered to principals who actually have admin
|
||||
// authority somewhere (can_elevate). Drops automatically on leave.
|
||||
if (access.can_elevate && elevation) {
|
||||
var row = el('label', 'profile-menu__item profile-menu__toggle');
|
||||
adminInput = document.createElement('input');
|
||||
adminInput.type = 'checkbox';
|
||||
adminInput.className = 'profile-menu__check';
|
||||
adminInput.checked = isElevated();
|
||||
adminInput.addEventListener('change', function () {
|
||||
if (adminInput.checked) elevation.setOn(); else elevation.setOff();
|
||||
});
|
||||
row.appendChild(adminInput);
|
||||
var txt = el('span', 'profile-menu__toggle-label');
|
||||
txt.appendChild(el('span', null, 'Admin mode'));
|
||||
txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page'));
|
||||
row.appendChild(txt);
|
||||
panelEl.appendChild(row);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
}
|
||||
|
||||
panelEl.appendChild(linkItem('Profile', '/.profile'));
|
||||
panelEl.appendChild(linkItem('Access tokens', '/.tokens'));
|
||||
// No "Sign out": authentication is the upstream proxy's concern
|
||||
// (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it
|
||||
// doesn't render a logout affordance.
|
||||
|
||||
// Portal the panel to <body>, not inside the header: the app's
|
||||
// layout creates stacking contexts that trap even a fixed+high
|
||||
// z-index panel below the content. As a direct body child it sits in
|
||||
// the root stacking context and reliably overlays everything.
|
||||
// position:fixed + positionPanel() keep it anchored to the button.
|
||||
document.body.appendChild(panelEl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (window.location.protocol === 'file:') return;
|
||||
elevation = window.zddc.elevation || null;
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.email) return; // unauthenticated / non-zddc backend
|
||||
var host = document.querySelector('.header-right');
|
||||
if (!host) return;
|
||||
|
||||
host.appendChild(build(access));
|
||||
syncArmed();
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (panelEl && panelEl.classList.contains('open')
|
||||
&& !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
|
||||
window.addEventListener('zddc:elevationchange', syncArmed);
|
||||
|
||||
window.zddc.profileMenu = { close: closeMenu };
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
||||
// shared/cap.js — client-side capability helpers for permission gating.
|
||||
|
|
|
|||
|
|
@ -78,6 +78,20 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
|
|||
/* Shape */
|
||||
--radius: 4px;
|
||||
|
||||
/* Spacing scale — referenced by the tables tool (tables/css/table.css).
|
||||
Were undefined (var() with no fallback → collapsed to 0), which left
|
||||
table cells unpadded and the table flush to the viewport edges. */
|
||||
--spacing-sm: 0.4rem;
|
||||
--spacing-md: 0.8rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
|
||||
/* Token aliases the tables tool references under --color-*/--radius-*
|
||||
names; map them to the canonical tokens (themed values flow through). */
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-border: var(--border);
|
||||
--color-bg-elevated: var(--bg-secondary);
|
||||
--radius-sm: var(--radius);
|
||||
|
||||
/* Typography. --font-display covers headings (Source Serif 4 — a refined
|
||||
transitional serif that reads as "engineering / document / serious"
|
||||
without being academic). --font is body UI text (IBM Plex Sans —
|
||||
|
|
@ -859,53 +873,10 @@ body.help-open .app-header {
|
|||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* 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/elevation.css — page-wide armed chrome for admin mode.
|
||||
The elevate CONTROL is the "Admin mode" item in the shared profile menu
|
||||
(shared/profile-menu.{js,css}); this file only styles the unmistakable
|
||||
"you are elevated" cues: the red viewport frame + the sticky banner. */
|
||||
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
easy to miss; these add an inescapable visual cue:
|
||||
|
|
@ -982,6 +953,118 @@ body.is-elevated::after {
|
|||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* shared/profile-menu.css — header account menu (upper-right).
|
||||
shared/profile-menu.js mounts a button into `.header-right` and toggles
|
||||
a dropdown with the signed-in email, Admin mode, Profile, Access tokens,
|
||||
and Sign out. Server mode only. */
|
||||
|
||||
.profile-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* The button: a small circular avatar showing the email initial. */
|
||||
.profile-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
}
|
||||
.profile-btn__avatar {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Armed (admin mode on): a red ring so the elevated state reads from the
|
||||
button even when the menu is closed — pairs with the page banner/frame. */
|
||||
.profile-btn--armed {
|
||||
box-shadow: 0 0 0 2px var(--danger, #dc3545);
|
||||
border-color: var(--danger, #dc3545);
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__panel {
|
||||
display: none;
|
||||
/* Fixed + JS-positioned from the button rect: an absolute panel gets
|
||||
trapped below the content layer by the app's stacking contexts, so
|
||||
anchor it to the viewport instead (profile-menu.js sets top/right). */
|
||||
position: fixed;
|
||||
min-width: 15rem;
|
||||
z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */
|
||||
background: var(--bg, #fff);
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: var(--radius, 6px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
|
||||
padding: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.profile-menu__panel.open { display: block; }
|
||||
|
||||
.profile-menu__id {
|
||||
padding: 0.35rem 0.55rem 0.45rem;
|
||||
}
|
||||
.profile-menu__email {
|
||||
font-weight: 600;
|
||||
color: var(--text, #222);
|
||||
word-break: break-all;
|
||||
}
|
||||
.profile-menu__role {
|
||||
margin-top: 0.1rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__sep {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0;
|
||||
background: var(--border, #eee);
|
||||
}
|
||||
|
||||
.profile-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: var(--radius, 4px);
|
||||
color: var(--text, #222);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.profile-menu__item:hover {
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.profile-menu__toggle { cursor: pointer; }
|
||||
.profile-menu__check {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger, #dc3545);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.profile-menu__toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.profile-menu__hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||
inherits the logo's box and adds a subtle hover/focus affordance
|
||||
so it reads as clickable without altering the logo's visual weight. */
|
||||
|
|
@ -2635,7 +2718,7 @@ dialog.modal--narrow {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Transmittal</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:16 · 382645b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3</span></span>
|
||||
</div>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- Publish split-button (Transmittal-specific primary action;
|
||||
|
|
@ -2647,12 +2730,6 @@ dialog.modal--narrow {
|
|||
</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 type="button" id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button type="button" id="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button>
|
||||
</div>
|
||||
|
|
@ -13403,26 +13480,31 @@ X.B(E,Y);return E}return J}())
|
|||
}
|
||||
}());
|
||||
|
||||
// shared/elevation.js — admin elevation via URL toggle.
|
||||
// shared/elevation.js — admin elevation state machine.
|
||||
//
|
||||
// Sudo-style model: admins behave as normal users by default; elevating
|
||||
// the session turns on admin escape hatches (WORM bypass, .zddc edit
|
||||
// authority, profile admin scaffolds). State is carried in a
|
||||
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
|
||||
// → zddc.Principal{Elevated}.
|
||||
// the session turns on admin escape hatches (WORM bypass, recursive
|
||||
// delete, rearranging records, profile admin scaffolds — NOT config-edit,
|
||||
// which is standing). State is carried in a `zddc-elevate=1` cookie that
|
||||
// the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
|
||||
//
|
||||
// Toggle is by URL query param — `?admin=true` to arm, `?admin=false`
|
||||
// (or the red banner's "Drop admin" button) to drop — so it's reachable
|
||||
// from ANY zddc-server page, not just ones that render a header control.
|
||||
// The cookie is the sticky state: it persists across navigation for its
|
||||
// Max-Age window, so the param need not stay in the URL (we strip it).
|
||||
// Arming is gated on /.profile/access `can_elevate`, so only real admins
|
||||
// can set it; a non-admin's ?admin=true is a silent no-op.
|
||||
// This module owns the STATE (cookie, armed chrome/banner, ephemeral
|
||||
// lifecycle, the change event) + exposes setOn/setOff/isElevated. The
|
||||
// on-page elevate CONTROL lives in the shared profile menu
|
||||
// (shared/profile-menu.js) — an "Admin mode" item shown only to
|
||||
// can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
|
||||
// into any URL is also honoured (gated on can_elevate), for deep links /
|
||||
// scripting.
|
||||
//
|
||||
// Applying the cookie reloads to the cleaned URL so the server re-renders
|
||||
// under the new state (admin scaffolds in some tool HTML are server-
|
||||
// rendered, so a client-only flip wouldn't reach them). The red viewport
|
||||
// border + banner (applyArmedChrome) reflect the cookie on every load.
|
||||
// Admin mode is EPHEMERAL — scoped to the page you turned it on:
|
||||
// * the cookie is a SESSION cookie (no Max-Age), and
|
||||
// * we clear it on `pagehide`, so navigating away / closing the tab
|
||||
// drops admin (you re-arm deliberately on the next page).
|
||||
// Because of that we apply state IN PLACE (no reload — a reload's pagehide
|
||||
// would race the clear). SPAs that server-render elevation-dependent data
|
||||
// (e.g. browse's listing verbs) listen for the `zddc:elevationchange`
|
||||
// event we emit and re-fetch. The red viewport border + banner
|
||||
// (applyArmedChrome) reflect the cookie, kept in sync on every change.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
|
|
@ -13443,16 +13525,43 @@ X.B(E,Y);return E}return J}())
|
|||
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';
|
||||
// shapes. No Max-Age → a SESSION cookie: it dies with the tab
|
||||
// and, combined with the pagehide handler below, is cleared the
|
||||
// moment you leave the page. Admin powers never silently
|
||||
// outlive the page you armed them on.
|
||||
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
|
||||
} else {
|
||||
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
|
||||
}
|
||||
}
|
||||
|
||||
// emitChange notifies same-page listeners (SPAs that server-render
|
||||
// elevation-dependent data, e.g. browse's listing verbs / editor
|
||||
// affordances) so they can re-fetch without a full reload.
|
||||
function emitChange() {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('zddc:elevationchange', {
|
||||
detail: { elevated: isElevated() }
|
||||
}));
|
||||
} catch (_e) { /* CustomEvent unsupported — non-fatal */ }
|
||||
}
|
||||
|
||||
// setOn / setOff are the single funnel for every arm/drop path (the
|
||||
// profile menu's Admin mode item, the ?admin= URL param, the banner's
|
||||
// Drop button). Each flips the cookie, re-paints the armed chrome, and
|
||||
// emits the change — no reload. The profile menu listens for the change
|
||||
// event to keep its checkbox + armed indicator in sync.
|
||||
function setOn() {
|
||||
setElevated(true);
|
||||
applyArmedChrome(true);
|
||||
emitChange();
|
||||
}
|
||||
function setOff() {
|
||||
setElevated(false);
|
||||
applyArmedChrome(false);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var resp = await fetch('/.profile/access', {
|
||||
|
|
@ -13498,34 +13607,26 @@ X.B(E,Y);return E}return J}())
|
|||
return u.pathname + (qs ? '?' + qs : '') + u.hash;
|
||||
}
|
||||
|
||||
// handleAdminParam applies a ?admin= request. Returns true when a
|
||||
// navigation (reload) is underway so the caller can stop. Enabling is
|
||||
// gated on can_elevate — a non-admin who types ?admin=true just gets
|
||||
// the param stripped, never a misleading red border. Disabling is open
|
||||
// (anyone may drop a cookie they somehow hold).
|
||||
async function handleAdminParam() {
|
||||
// handleAdminParam applies a ?admin= request IN PLACE (no reload — see
|
||||
// the module header on why reloads would race the pagehide-clear).
|
||||
// Enabling is gated on can_elevate — a non-admin who types ?admin=true
|
||||
// just gets the param stripped, never a misleading red border.
|
||||
// Disabling is open (anyone may drop a cookie they somehow hold).
|
||||
// `access` (a prefetched /.profile/access, may be null) lets init reuse
|
||||
// its single fetch instead of issuing a second one.
|
||||
async function handleAdminParam(access) {
|
||||
var want = adminParam();
|
||||
if (want === null) return false;
|
||||
if (want === null) return;
|
||||
var clean = urlWithoutAdmin();
|
||||
if (want === isElevated()) {
|
||||
// Already in the requested state — just clean the URL, no reload.
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
return false;
|
||||
}
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
if (want === isElevated()) return; // already in the requested state
|
||||
if (want === true) {
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.can_elevate) {
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
return false;
|
||||
}
|
||||
setElevated(true);
|
||||
if (access === undefined) access = await fetchAccess();
|
||||
if (!access || !access.can_elevate) return; // silent no-op
|
||||
setOn();
|
||||
} else {
|
||||
setElevated(false);
|
||||
setOff();
|
||||
}
|
||||
// Navigate to the clean URL (a real load, so the server re-renders
|
||||
// under the new cookie) and replace history so Back is safe.
|
||||
window.location.replace(clean);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Page-wide affordances when elevation is active. The toggle alone
|
||||
|
|
@ -13556,10 +13657,7 @@ X.B(E,Y);return E}return J}())
|
|||
+ '</button>';
|
||||
document.body.insertBefore(banner, document.body.firstChild);
|
||||
var off = banner.querySelector('#elevation-banner-off');
|
||||
if (off) off.addEventListener('click', function () {
|
||||
setElevated(false);
|
||||
window.location.reload();
|
||||
});
|
||||
if (off) off.addEventListener('click', function () { setOff(); });
|
||||
}
|
||||
} else if (banner) {
|
||||
banner.parentNode.removeChild(banner);
|
||||
|
|
@ -13567,16 +13665,30 @@ X.B(E,Y);return E}return J}())
|
|||
}
|
||||
|
||||
async function init() {
|
||||
// Apply (or tear down) the red border + banner from the cookie on
|
||||
// every page load — admin mode is toggled by URL, but the armed
|
||||
// chrome must surface everywhere so the user can't accidentally
|
||||
// write through an elevated context on a page they didn't toggle.
|
||||
// file:// (offline FS-Access mode) has no server to elevate against.
|
||||
if (window.location.protocol === 'file:') return;
|
||||
|
||||
// Reflect the cookie's armed chrome on every load (a leftover from a
|
||||
// not-yet-fired pagehide, or an arrived-with ?admin link).
|
||||
applyArmedChrome(isElevated());
|
||||
|
||||
// Honour ?admin=true|false typed into any zddc-server URL. There's
|
||||
// no on-screen toggle anymore — the URL is the enable path and the
|
||||
// red banner's "Drop admin" button is the one-click disable.
|
||||
// Honour ?admin=true|false typed into any URL — handleAdminParam
|
||||
// fetches /.profile/access itself to gate arming on can_elevate. The
|
||||
// on-page elevate control lives in the shared profile menu
|
||||
// (shared/profile-menu.js), which calls setOn/setOff and listens for
|
||||
// zddc:elevationchange to keep its checkbox + armed ring in sync.
|
||||
await handleAdminParam();
|
||||
|
||||
// Admin mode is per-page: clear the cookie when the page goes away so
|
||||
// it never persists past a navigation.
|
||||
window.addEventListener('pagehide', function () {
|
||||
if (isElevated()) setElevated(false);
|
||||
});
|
||||
// bfcache can restore a page whose pagehide already cleared the
|
||||
// cookie — re-sync the armed chrome so chrome ≠ cookie can't happen.
|
||||
window.addEventListener('pageshow', function (e) {
|
||||
if (e.persisted) applyArmedChrome(isElevated());
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
|
@ -13585,7 +13697,178 @@ X.B(E,Y);return E}return J}())
|
|||
init();
|
||||
}
|
||||
|
||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||
window.zddc.elevation = {
|
||||
isElevated: isElevated,
|
||||
setElevated: setElevated,
|
||||
setOn: setOn,
|
||||
setOff: setOff
|
||||
};
|
||||
})();
|
||||
|
||||
// shared/profile-menu.js — account menu in the header's upper-right.
|
||||
//
|
||||
// Replaces the old floating elevation toggle. Admin mode is now one item in
|
||||
// this dropdown, alongside the signed-in email, Profile, and Access tokens.
|
||||
// Mounts into the tool header's `.header-right` cluster (every tool ships one)
|
||||
// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome
|
||||
// / ephemeral state machine stays in shared/elevation.js.
|
||||
//
|
||||
// No logout: authentication is the upstream proxy's concern (oauth2-proxy /
|
||||
// Authelia) — ZDDC owns no session, so it doesn't touch sign-out.
|
||||
//
|
||||
// Server mode only: it reads /.profile/access for the email + can_elevate.
|
||||
// On file:// (offline FS-Access mode) there's no server account, so nothing
|
||||
// renders.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.zddc) window.zddc = {};
|
||||
if (window.zddc.profileMenu) return;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var r = await fetch('/.profile/access', {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (_e) { return null; }
|
||||
}
|
||||
|
||||
var elevation = null;
|
||||
var panelEl = null, btnEl = null, adminInput = null;
|
||||
|
||||
function isElevated() {
|
||||
return !!(elevation && elevation.isElevated && elevation.isElevated());
|
||||
}
|
||||
|
||||
// Keep the button's armed ring + the menu checkbox in lockstep with the
|
||||
// elevation cookie (flipped here, by ?admin=, or by the banner's Drop).
|
||||
function syncArmed() {
|
||||
if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated());
|
||||
if (adminInput) adminInput.checked = isElevated();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (panelEl) panelEl.classList.remove('open');
|
||||
if (btnEl) btnEl.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
// The panel is position:fixed (to escape the app's stacking contexts), so
|
||||
// anchor it to the button rect — top just below it, right-aligned.
|
||||
function positionPanel() {
|
||||
var r = btnEl.getBoundingClientRect();
|
||||
panelEl.style.top = (r.bottom + 4) + 'px';
|
||||
panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
|
||||
panelEl.style.left = 'auto';
|
||||
}
|
||||
function toggleMenu() {
|
||||
if (!panelEl) return;
|
||||
var open = panelEl.classList.toggle('open');
|
||||
if (open) positionPanel();
|
||||
btnEl.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function linkItem(text, href) {
|
||||
var a = el('a', 'profile-menu__item', text);
|
||||
a.href = href;
|
||||
a.setAttribute('role', 'menuitem');
|
||||
return a;
|
||||
}
|
||||
|
||||
function build(access) {
|
||||
var wrap = el('div', 'profile-menu');
|
||||
|
||||
btnEl = el('button', 'btn btn-secondary profile-btn');
|
||||
btnEl.type = 'button';
|
||||
btnEl.id = 'profile-btn';
|
||||
btnEl.title = 'Account: ' + (access.email || 'signed in');
|
||||
btnEl.setAttribute('aria-haspopup', 'menu');
|
||||
btnEl.setAttribute('aria-expanded', 'false');
|
||||
var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase();
|
||||
btnEl.appendChild(el('span', 'profile-btn__avatar', initial));
|
||||
btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); });
|
||||
wrap.appendChild(btnEl);
|
||||
|
||||
panelEl = el('div', 'profile-menu__panel');
|
||||
panelEl.setAttribute('role', 'menu');
|
||||
|
||||
var id = el('div', 'profile-menu__id');
|
||||
id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)'));
|
||||
if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin'));
|
||||
else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin'));
|
||||
panelEl.appendChild(id);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
|
||||
// Admin mode — only offered to principals who actually have admin
|
||||
// authority somewhere (can_elevate). Drops automatically on leave.
|
||||
if (access.can_elevate && elevation) {
|
||||
var row = el('label', 'profile-menu__item profile-menu__toggle');
|
||||
adminInput = document.createElement('input');
|
||||
adminInput.type = 'checkbox';
|
||||
adminInput.className = 'profile-menu__check';
|
||||
adminInput.checked = isElevated();
|
||||
adminInput.addEventListener('change', function () {
|
||||
if (adminInput.checked) elevation.setOn(); else elevation.setOff();
|
||||
});
|
||||
row.appendChild(adminInput);
|
||||
var txt = el('span', 'profile-menu__toggle-label');
|
||||
txt.appendChild(el('span', null, 'Admin mode'));
|
||||
txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page'));
|
||||
row.appendChild(txt);
|
||||
panelEl.appendChild(row);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
}
|
||||
|
||||
panelEl.appendChild(linkItem('Profile', '/.profile'));
|
||||
panelEl.appendChild(linkItem('Access tokens', '/.tokens'));
|
||||
// No "Sign out": authentication is the upstream proxy's concern
|
||||
// (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it
|
||||
// doesn't render a logout affordance.
|
||||
|
||||
// Portal the panel to <body>, not inside the header: the app's
|
||||
// layout creates stacking contexts that trap even a fixed+high
|
||||
// z-index panel below the content. As a direct body child it sits in
|
||||
// the root stacking context and reliably overlays everything.
|
||||
// position:fixed + positionPanel() keep it anchored to the button.
|
||||
document.body.appendChild(panelEl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (window.location.protocol === 'file:') return;
|
||||
elevation = window.zddc.elevation || null;
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.email) return; // unauthenticated / non-zddc backend
|
||||
var host = document.querySelector('.header-right');
|
||||
if (!host) return;
|
||||
|
||||
host.appendChild(build(access));
|
||||
syncArmed();
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (panelEl && panelEl.classList.contains('open')
|
||||
&& !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
|
||||
window.addEventListener('zddc:elevationchange', syncArmed);
|
||||
|
||||
window.zddc.profileMenu = { close: closeMenu };
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
||||
// shared/cap.js — client-side capability helpers for permission gating.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||
archive=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
transmittal=v0.0.27-beta · 2026-06-05 12:41:16 · 382645b
|
||||
classifier=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
landing=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
form=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
tables=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
browse=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
archive=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
|
||||
transmittal=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
|
||||
classifier=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
|
||||
landing=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
|
||||
form=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
|
||||
tables=v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3
|
||||
browse=v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3
|
||||
|
|
|
|||
|
|
@ -58,6 +58,60 @@ type Metadata struct {
|
|||
NoTOC bool
|
||||
}
|
||||
|
||||
// FrontMatterField is a YAML front-matter key the conversion pipeline honours,
|
||||
// paired with a short human hint. Clients (the markdown editor) use this to
|
||||
// communicate the recognised fields to authors while still allowing arbitrary
|
||||
// keys (anything else is passed straight through to pandoc).
|
||||
type FrontMatterField struct {
|
||||
Name string `json:"name"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
// RecognizedFrontMatter is the single source of truth for the front-matter keys
|
||||
// the converter + doctype templates honour, in a sensible authoring order. All
|
||||
// are optional. title/tracking_number/revision/status are normally derived from
|
||||
// the filename and client/project/project_number/contractor from the .zddc
|
||||
// `convert:` cascade — listing them here lets an author OVERRIDE those. doctype,
|
||||
// numbering, date and custom_header have no other source, so they're the ones a
|
||||
// user most needs told about.
|
||||
func RecognizedFrontMatter() []FrontMatterField {
|
||||
return []FrontMatterField{
|
||||
{"doctype", "report | letter | specification"},
|
||||
{"numbering", "true to number headings (default false)"},
|
||||
{"title", "set by the filename (the filename wins on mismatch)"},
|
||||
{"tracking_number", "set by the filename (the filename wins on mismatch)"},
|
||||
{"revision", "set by the filename (the filename wins on mismatch)"},
|
||||
{"status", "set by the filename (the filename wins on mismatch)"},
|
||||
{"date", "document date (free text)"},
|
||||
{"custom_header", "extra line shown in the document header"},
|
||||
{"client", "overrides the .zddc convert: cascade"},
|
||||
{"project", "overrides the .zddc convert: cascade"},
|
||||
{"project_number", "overrides the .zddc convert: cascade"},
|
||||
{"contractor", "overrides the .zddc convert: cascade"},
|
||||
}
|
||||
}
|
||||
|
||||
// FrontMatterPlaceholder renders RecognizedFrontMatter as greyed editor
|
||||
// placeholder text: a leading note, then one "key: # hint" line per field.
|
||||
// Shown when the front-matter box is empty; it inserts nothing (placeholder
|
||||
// vanishes once the author types), so arbitrary keys remain free.
|
||||
func FrontMatterPlaceholder() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Recognised front matter (all optional; any other key is allowed):\n")
|
||||
fields := RecognizedFrontMatter()
|
||||
width := 0
|
||||
for _, f := range fields {
|
||||
if len(f.Name) > width {
|
||||
width = len(f.Name)
|
||||
}
|
||||
}
|
||||
for _, f := range fields {
|
||||
pad := strings.Repeat(" ", width-len(f.Name))
|
||||
b.WriteString(f.Name + ":" + pad + " # " + f.Hint + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TemplateSet is the bundle of files written to the per-call scratch dir for an
|
||||
// HTML render: the chosen doctype template (Name) plus every partial it may
|
||||
// include. pandoc resolves `$partial()$` includes from the template's own
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
// Empty-listing fallback for cascade-declared paths. A fresh
|
||||
// project doesn't have working/, staging/, reviewing/, or
|
||||
// archive/<party>/incoming/ on disk until something is
|
||||
// written into them — but the cascade (defaults.zddc.yaml
|
||||
// written into them — but the cascade (internal/zddc/defaults/
|
||||
// plus any on-disk overrides) declares them via paths:, so
|
||||
// the stage-strip / file nav can link unconditionally.
|
||||
// Returning [] gives a usable empty view (the tables peers
|
||||
|
|
@ -80,9 +80,13 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
result := make([]listing.FileInfo, 0, len(entries))
|
||||
|
||||
// Display overrides for this directory's children, sourced from
|
||||
// THIS directory's .zddc `display:` map. Built once and looked up
|
||||
// case-insensitively per entry. Empty map = no overrides.
|
||||
displayMap := readDisplayMap(absDir)
|
||||
// THIS directory's cascade-resolved `display:` map (embedded defaults +
|
||||
// ancestor + on-disk overrides). Built once and looked up case-
|
||||
// insensitively per entry. Empty/nil = no overrides. Cascade-resolved
|
||||
// (not just the on-disk file) so the baked-in default labels — archive →
|
||||
// "Archive", mdl → "Master Deliverables List", … — render with no
|
||||
// per-project config, while an operator's on-disk display: still wins.
|
||||
displayMap := zddc.DisplayAt(fsRoot, absDir)
|
||||
|
||||
// Set of cascade-declared child names (lowercase) for this dir.
|
||||
// Entries with a matching name get Declared=true so clients can
|
||||
|
|
@ -170,6 +174,10 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
Declared: declared,
|
||||
Title: title,
|
||||
Verbs: subVerbs.String(),
|
||||
// Cascade-resolved default tool for this child dir, so the
|
||||
// browse client can render a tool-typed dir (e.g. tables) as
|
||||
// a click-to-open leaf without re-walking the cascade.
|
||||
DefaultTool: zddc.DefaultToolAt(fsRoot, subAbs),
|
||||
}
|
||||
result = append(result, fi)
|
||||
continue
|
||||
|
|
@ -384,30 +392,14 @@ func virtualCanonicalFolders(ctx context.Context, decider policy.Decider, fsRoot
|
|||
DisplayName: lookupDisplay(displayMap, name),
|
||||
Declared: true, // synthesized entries are by definition cascade-declared
|
||||
Verbs: verbs.String(),
|
||||
// Cascade default tool for this virtual peer — mdl/rsk/ssr resolve
|
||||
// to "tables", which browse renders as a click-to-table leaf.
|
||||
DefaultTool: zddc.DefaultToolAt(fsRoot, childAbs),
|
||||
})
|
||||
}
|
||||
return synth
|
||||
}
|
||||
|
||||
// readDisplayMap parses dirAbs/.zddc and returns its Display map (or
|
||||
// nil when the file doesn't exist or has no display block). All keys
|
||||
// are case-folded to lowercase so lookupDisplay's case-insensitive
|
||||
// match is a simple map read.
|
||||
func readDisplayMap(dirAbs string) map[string]string {
|
||||
zf, err := zddc.ParseFile(filepath.Join(dirAbs, ".zddc"))
|
||||
if err != nil || len(zf.Display) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(zf.Display))
|
||||
for k, v := range zf.Display {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
out[strings.ToLower(strings.TrimSpace(k))] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lookupDisplay returns the custom display label for name (matched
|
||||
// case-insensitively against displayMap's keys), or "" when no
|
||||
// override applies.
|
||||
|
|
|
|||
|
|
@ -114,16 +114,18 @@ func TestInvariant_UnelevatedAdminCannotBypassWorm(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestInvariant_UnelevatedAdminCannotEditZddc(t *testing.T) {
|
||||
// .zddc edits route through the decider as ActionAdmin. The bypass
|
||||
// for elevated admins fires only when Principal.Elevated is true.
|
||||
// Exercised at the HTTP boundary: a PUT to .zddc from an un-elevated
|
||||
// super-admin must return Forbidden.
|
||||
func TestInvariant_UnelevatedAdminCanEditZddc(t *testing.T) {
|
||||
// Config-edit is a STANDING permission: .zddc edits route through the
|
||||
// decider as ActionAdmin, which IsConfigEditor grants to a subtree
|
||||
// admin WITHOUT elevation (elevation is reserved for the WORM/
|
||||
// destructive overrides — see TestInvariant_UnelevatedAdminNoSilentBypass).
|
||||
// Exercised at the HTTP boundary: a PUT to a .zddc the principal
|
||||
// administers, from an un-elevated admin, must succeed.
|
||||
cfg, _ := invariantsFixture(t)
|
||||
target := "/Project-1/archive/Acme/working/.zddc"
|
||||
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("title: mutated\n"), "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("un-elevated admin .zddc write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
|
||||
t.Fatalf("un-elevated admin .zddc write blocked: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -331,25 +333,30 @@ func TestInvariant_ZddcPutMatrix(t *testing.T) {
|
|||
who principal
|
||||
want int
|
||||
}{
|
||||
// Root .zddc
|
||||
// Root .zddc. Config-edit is standing for an admin OF that path: the
|
||||
// root super-admin edits the root .zddc un-elevated; a subtree admin
|
||||
// (alice) does NOT administer root, so she's denied either way.
|
||||
{"root admin elevated → root .zddc", "/.zddc", rootAdminElevated, ok},
|
||||
{"root admin un-elevated → root .zddc", "/.zddc", rootAdminUnelevated, den},
|
||||
{"root admin un-elevated → root .zddc", "/.zddc", rootAdminUnelevated, ok},
|
||||
{"subtree admin elevated → root .zddc", "/.zddc", subtreeAdminElevated, den},
|
||||
{"subtree admin un-elevated → root .zddc", "/.zddc", subtreeAdminUnelevated, den},
|
||||
{"non-admin → root .zddc", "/.zddc", nonAdmin, den},
|
||||
{"anonymous → root .zddc", "/.zddc", anon, den},
|
||||
|
||||
// Project .zddc (no on-disk file yet — PUT creates it)
|
||||
// Project .zddc (no on-disk file yet — PUT creates it). The root admin
|
||||
// administers all subtrees (cascade), so standing-edits it un-elevated;
|
||||
// alice's admin scope is below this path, so she's out-of-scope.
|
||||
{"root admin elevated → project .zddc", "/Project-1/.zddc", rootAdminElevated, http.StatusCreated},
|
||||
{"root admin un-elevated → project .zddc", "/Project-1/.zddc", rootAdminUnelevated, den},
|
||||
{"root admin un-elevated → project .zddc", "/Project-1/.zddc", rootAdminUnelevated, http.StatusCreated},
|
||||
{"subtree admin elevated (out-of-scope) → project .zddc", "/Project-1/.zddc", subtreeAdminElevated, den},
|
||||
{"non-admin → project .zddc", "/Project-1/.zddc", nonAdmin, den},
|
||||
|
||||
// Subtree .zddc (alice administers this subtree)
|
||||
// Subtree .zddc (alice administers this subtree). Both the root admin
|
||||
// and alice standing-edit it un-elevated; non-admins/anon denied.
|
||||
{"root admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminElevated, ok},
|
||||
{"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, den},
|
||||
{"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, ok},
|
||||
{"subtree admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminElevated, ok},
|
||||
{"subtree admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, den},
|
||||
{"subtree admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, ok},
|
||||
{"non-admin → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", nonAdmin, den},
|
||||
{"anonymous → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", anon, den},
|
||||
}
|
||||
|
|
@ -392,10 +399,11 @@ func TestInvariant_ZddcDeleteMatrix(t *testing.T) {
|
|||
who principal
|
||||
want int
|
||||
}{
|
||||
// .zddc DELETE is also ActionAdmin → standing config-edit within scope.
|
||||
{"root admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminElevated, http.StatusNoContent},
|
||||
{"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, http.StatusForbidden},
|
||||
{"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, http.StatusNoContent},
|
||||
{"subtree admin elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminElevated, http.StatusNoContent},
|
||||
{"subtree admin un-elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden},
|
||||
{"subtree admin un-elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, http.StatusNoContent},
|
||||
{"non-admin → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", nonAdmin, http.StatusForbidden},
|
||||
}
|
||||
|
||||
|
|
@ -413,11 +421,13 @@ func TestInvariant_ZddcDeleteMatrix(t *testing.T) {
|
|||
// ── Invariant 11 — anti-bypass: un-elevated admin gets nothing extra ──────
|
||||
|
||||
// TestInvariant_UnelevatedAdminNoSilentBypass is the anti-test for the
|
||||
// elevation gate. For every (admin-flavour × action) tuple, an
|
||||
// un-elevated admin must behave exactly like a non-admin: they may
|
||||
// only do what an explicit ACL grant permits. The fixture's admin and
|
||||
// alice both have NO baseline ACL grant outside their admin scope, so
|
||||
// every action below MUST 403 — any pass indicates a bypass leak.
|
||||
// elevation gate, scoped to what elevation actually guards now: the
|
||||
// WORM/destructive overrides. Config-edit (ActionAdmin on .zddc) is a
|
||||
// STANDING permission and is exercised separately (ZddcPutMatrix /
|
||||
// ZddcDeleteMatrix / UnelevatedAdminCanEditZddc) — it's deliberately NOT
|
||||
// here. For every (admin-flavour × probe) below, an un-elevated admin must
|
||||
// behave exactly like a non-admin: WORM records and other principals' homes
|
||||
// stay off-limits without elevation. Any pass indicates a bypass leak.
|
||||
func TestInvariant_UnelevatedAdminNoSilentBypass(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
type op struct {
|
||||
|
|
@ -427,10 +437,6 @@ func TestInvariant_UnelevatedAdminNoSilentBypass(t *testing.T) {
|
|||
op string
|
||||
}
|
||||
probes := []op{
|
||||
// .zddc writes (ActionAdmin)
|
||||
{http.MethodPut, "/.zddc", []byte("title: x\n"), ""},
|
||||
{http.MethodPut, "/Project-1/archive/Acme/working/.zddc", []byte("title: x\n"), ""},
|
||||
{http.MethodDelete, "/Project-1/archive/Acme/working/.zddc", nil, ""},
|
||||
// WORM writes (ActionWrite / ActionCreate stripped)
|
||||
{http.MethodPut, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", []byte("# mutate\n"), ""},
|
||||
{http.MethodPut, "/Project-1/archive/Acme/received/Acme-0042/new.pdf", []byte("%PDF\n"), ""},
|
||||
|
|
@ -519,3 +525,25 @@ func dumpBody(rec *httptest.ResponseRecorder) string {
|
|||
s := rec.Body.String()
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// Regression: "I'm a document_controller but creating a folder in working/
|
||||
// says I need document-controller permissions." A DC (role member at the site
|
||||
// root, NOT an admin, un-elevated) must be able to (1) register a party by
|
||||
// creating ssr/<party>.yaml and (2) create folders under working/<party>/,
|
||||
// per the embedded per-peer grants (ssr → document_controller rwc; working →
|
||||
// document_controller rwcda). Exercises role resolution from a deep peer level
|
||||
// back to the root role definition.
|
||||
func TestInvariant_DocumentControllerRegistersPartyAndCreatesInWorking(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
// 1. Register a new party: create ssr/<party>.yaml.
|
||||
rec := doReq(cfg, http.MethodPut, "/Project-1/ssr/Beta.yaml", "bob@example.com", false, []byte("kind: SSR\n"), "")
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Fatalf("DC register party ssr/Beta.yaml: status=%d body=%s (want 201/200)", rec.Code, rec.Body.String())
|
||||
}
|
||||
zddc.InvalidateCache(cfg.Root)
|
||||
// 2. Create a folder under working/<party>/.
|
||||
rec2 := doReq(cfg, http.MethodPost, "/Project-1/working/Beta/draft/", "bob@example.com", false, nil, "mkdir")
|
||||
if rec2.Code != http.StatusCreated && rec2.Code != http.StatusOK {
|
||||
t.Fatalf("DC mkdir working/Beta/draft: status=%d body=%s (want 201/200)", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package handler
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
|
@ -346,3 +347,34 @@ func mapConvertError(w http.ResponseWriter, err error, format string) {
|
|||
slog.Warn("convert: unexpected error", "format", format, "err", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// FrontMatterTemplatePath is the JSON endpoint that exposes the recognised
|
||||
// markdown front-matter fields + a ready-made greyed placeholder string. The
|
||||
// browse markdown editor fetches it (server mode) to communicate the valid
|
||||
// keys to authors without baking the list into client JS — it stays in sync
|
||||
// with convert.RecognizedFrontMatter, the server-side source of truth.
|
||||
const FrontMatterTemplatePath = "/.api/frontmatter"
|
||||
|
||||
// ServeFrontMatterTemplate returns the recognised front-matter fields and the
|
||||
// editor placeholder as JSON. Read-only, no auth gate: it leaks nothing beyond
|
||||
// the documented field names. GET/HEAD only.
|
||||
func ServeFrontMatterTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
payload := struct {
|
||||
Placeholder string `json:"placeholder"`
|
||||
Fields []convert.FrontMatterField `json:"fields"`
|
||||
}{
|
||||
Placeholder: convert.FrontMatterPlaceholder(),
|
||||
Fields: convert.RecognizedFrontMatter(),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "max-age=300")
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -63,3 +67,46 @@ func TestRecognizeVirtualConvert_MatrixAndPrecedence(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeFrontMatterTemplate(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ServeFrontMatterTemplate(rec, httptest.NewRequest(http.MethodGet, FrontMatterTemplatePath, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want 200", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||
t.Errorf("Content-Type=%q, want application/json", ct)
|
||||
}
|
||||
var payload struct {
|
||||
Placeholder string `json:"placeholder"`
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Hint string `json:"hint"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
if len(payload.Fields) == 0 {
|
||||
t.Fatal("fields empty")
|
||||
}
|
||||
// doctype/numbering have no other source; revision/status are template
|
||||
// fields an author can override here — all must be communicated.
|
||||
for _, want := range []string{"doctype", "numbering", "revision", "status", "tracking_number"} {
|
||||
if !strings.Contains(payload.Placeholder, want) {
|
||||
t.Errorf("placeholder missing %q: %q", want, payload.Placeholder)
|
||||
}
|
||||
}
|
||||
// HEAD returns headers, no body.
|
||||
hrec := httptest.NewRecorder()
|
||||
ServeFrontMatterTemplate(hrec, httptest.NewRequest(http.MethodHead, FrontMatterTemplatePath, nil))
|
||||
if hrec.Code != http.StatusOK || hrec.Body.Len() != 0 {
|
||||
t.Errorf("HEAD: status=%d bodylen=%d, want 200 + empty", hrec.Code, hrec.Body.Len())
|
||||
}
|
||||
// Non-GET/HEAD is rejected.
|
||||
prec := httptest.NewRecorder()
|
||||
ServeFrontMatterTemplate(prec, httptest.NewRequest(http.MethodPost, FrontMatterTemplatePath, nil))
|
||||
if prec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("POST: status=%d, want 405", prec.Code)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
152
zddc/internal/handler/defaults_matrix_test.go
Normal file
152
zddc/internal/handler/defaults_matrix_test.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package handler
|
||||
|
||||
// Layer 2 — the SHIPPED DEFAULT POLICY contract.
|
||||
//
|
||||
// This is the executable truth table for the embedded defaults
|
||||
// (internal/zddc/defaults/): role × canonical-path × verb → allow/deny.
|
||||
// It pins the document-control access model so a change to the defaults — OR to
|
||||
// the engine that resolves them — can't silently alter who-can-do-what. (When
|
||||
// the defaults later move into a project-root .zddc.zip of per-depth .zddc
|
||||
// files, this test is unchanged: it asserts EFFECTIVE policy, not where the
|
||||
// bytes live.)
|
||||
//
|
||||
// Two layers, deliberately separate:
|
||||
// - Layer 1 (engine follows whatever policy says): policy.TestInternalDecider_
|
||||
// CascadeScenarios + internal/zddc/{acl,roles,worm}_test.go (synthetic
|
||||
// policies) + internal/policy/parity_test.go (InternalDecider ↔ OPA).
|
||||
// - Layer 2 (the shipped defaults are correct): THIS file.
|
||||
//
|
||||
// Decisions go through the same decider the server uses (InternalDecider, which
|
||||
// applies the cascade + WORM mask + active-admin bypass), evaluated at the
|
||||
// target's logical parent — mirroring authorizeAction. The HTTP plumbing that
|
||||
// chooses that path is covered separately by the auth_invariants tests.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// defaultsMatrixFixture is a minimal operator deployment: it only populates the
|
||||
// three standard roles (which the embedded defaults ship empty) plus one admin,
|
||||
// and registers party Acme (party_source: ssr gates the peers). Every grant in
|
||||
// the matrix below therefore comes from the embedded defaults, not the fixture.
|
||||
func defaultsMatrixFixture(t *testing.T) config.Config {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
mustWriteHelper(t, filepath.Join(root, ".zddc"),
|
||||
"admins:\n - admin@x\n"+
|
||||
"roles:\n"+
|
||||
" document_controller:\n members: [dc@x]\n"+
|
||||
" project_team:\n members: [team@x]\n"+
|
||||
" observer:\n members: [obs@x]\n")
|
||||
mustWriteHelper(t, filepath.Join(root, "Proj/ssr/Acme.yaml"), "kind: SSR\n")
|
||||
zddc.InvalidateCache(root)
|
||||
return config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", MaxWriteBytes: 64 * 1024}
|
||||
}
|
||||
|
||||
// canDo reports whether <email> (elevated?) may perform <action> on content in
|
||||
// <dir> — the chain is resolved at <dir> (the logical parent of the child being
|
||||
// acted on) and routed through the internal decider, exactly as the server's
|
||||
// authorizeAction does for a create/write/delete/read.
|
||||
func canDo(t *testing.T, cfg config.Config, email string, elevated bool, dir, action string) bool {
|
||||
t.Helper()
|
||||
p := zddc.Principal{Email: email, Elevated: elevated}
|
||||
chain, err := zddc.EffectivePolicy(cfg.Root, filepath.Join(cfg.Root, filepath.FromSlash(dir)))
|
||||
if err != nil {
|
||||
t.Fatalf("EffectivePolicy(%s): %v", dir, err)
|
||||
}
|
||||
allowed, _ := policy.AllowActionFromChainP(
|
||||
context.Background(), &policy.InternalDecider{}, chain, p, "/"+dir+"/probe", action)
|
||||
return allowed
|
||||
}
|
||||
|
||||
func TestDefaultPolicyMatrix(t *testing.T) {
|
||||
cfg := defaultsMatrixFixture(t)
|
||||
const (
|
||||
R = policy.ActionRead
|
||||
W = policy.ActionWrite
|
||||
C = policy.ActionCreate
|
||||
D = policy.ActionDelete
|
||||
)
|
||||
cases := []struct {
|
||||
note string
|
||||
who string
|
||||
elev bool
|
||||
dir string
|
||||
action string
|
||||
want bool
|
||||
}{
|
||||
// ── Project root: standard peers only; no create for anyone ──────────
|
||||
{"team: read project root", "team@x", false, "Proj", R, true},
|
||||
{"observer: read project root", "obs@x", false, "Proj", R, true},
|
||||
{"team: NO create at project root", "team@x", false, "Proj", C, false},
|
||||
{"DC: NO create at project root", "dc@x", false, "Proj", C, false},
|
||||
|
||||
// ── working/<party>: DC rwcda, team cr, observer r ───────────────────
|
||||
{"DC: create in working", "dc@x", false, "Proj/working/Acme", C, true},
|
||||
{"team: create in working", "team@x", false, "Proj/working/Acme", C, true},
|
||||
{"team: read working", "team@x", false, "Proj/working/Acme", R, true},
|
||||
{"observer: read working", "obs@x", false, "Proj/working/Acme", R, true},
|
||||
{"observer: NO create in working", "obs@x", false, "Proj/working/Acme", C, false},
|
||||
// nested under working — the path the authorizeAction bug denied
|
||||
{"DC: create nested in working", "dc@x", false, "Proj/working/Acme/sub", C, true},
|
||||
{"team: create nested in working", "team@x", false, "Proj/working/Acme/sub", C, true},
|
||||
|
||||
// ── staging / reviewing: team cr ─────────────────────────────────────
|
||||
{"team: create in staging", "team@x", false, "Proj/staging/Acme", C, true},
|
||||
{"team: create in reviewing", "team@x", false, "Proj/reviewing/Acme", C, true},
|
||||
|
||||
// ── incoming: DC rwcd, team read-only ────────────────────────────────
|
||||
{"DC: create in incoming", "dc@x", false, "Proj/incoming/Acme", C, true},
|
||||
{"team: NO create in incoming", "team@x", false, "Proj/incoming/Acme", C, false},
|
||||
{"team: read incoming", "team@x", false, "Proj/incoming/Acme", R, true},
|
||||
|
||||
// ── ssr (party registry): DC rwc, team read-only ─────────────────────
|
||||
{"DC: register party (create in ssr)", "dc@x", false, "Proj/ssr", C, true},
|
||||
{"team: NO create in ssr", "team@x", false, "Proj/ssr", C, false},
|
||||
{"team: read ssr", "team@x", false, "Proj/ssr", R, true},
|
||||
|
||||
// ── mdl / rsk registers: DC rwcd, team rwc (no delete), observer r ───
|
||||
{"DC: create mdl row", "dc@x", false, "Proj/mdl/Acme", C, true},
|
||||
{"DC: delete mdl row", "dc@x", false, "Proj/mdl/Acme", D, true},
|
||||
{"team: create mdl row", "team@x", false, "Proj/mdl/Acme", C, true},
|
||||
{"team: edit mdl row", "team@x", false, "Proj/mdl/Acme", W, true},
|
||||
{"team: NO delete mdl row", "team@x", false, "Proj/mdl/Acme", D, false},
|
||||
{"observer: NO create mdl row", "obs@x", false, "Proj/mdl/Acme", C, false},
|
||||
{"team: create rsk row", "team@x", false, "Proj/rsk/Acme", C, true},
|
||||
{"team: edit rsk row", "team@x", false, "Proj/rsk/Acme", W, true},
|
||||
{"team: NO delete rsk row", "team@x", false, "Proj/rsk/Acme", D, false},
|
||||
|
||||
// ── archive WORM: DC create-once, no write/delete; others read ───────
|
||||
{"DC: worm-create in received", "dc@x", false, "Proj/archive/Acme/received", C, true},
|
||||
{"DC: NO write in WORM received", "dc@x", false, "Proj/archive/Acme/received", W, false},
|
||||
{"DC: NO delete in WORM issued", "dc@x", false, "Proj/archive/Acme/issued", D, false},
|
||||
{"team: NO create in archive", "team@x", false, "Proj/archive/Acme/issued", C, false},
|
||||
{"team: read archive", "team@x", false, "Proj/archive/Acme/issued", R, true},
|
||||
|
||||
// ── Elevated admin: full bypass (the human escape hatch) ─────────────
|
||||
{"elevated admin: bypass WORM write", "admin@x", true, "Proj/archive/Acme/issued", W, true},
|
||||
{"elevated admin: create in working", "admin@x", true, "Proj/working/Acme", C, true},
|
||||
|
||||
// ── Un-elevated admin: NO bypass; not in any role → no grant ─────────
|
||||
{"un-elevated admin: NO WORM bypass", "admin@x", false, "Proj/archive/Acme/issued", W, false},
|
||||
{"un-elevated admin: NO create in working", "admin@x", false, "Proj/working/Acme", C, false},
|
||||
|
||||
// ── Anonymous: nothing (a .zddc exists → no public default) ──────────
|
||||
{"anon: NO read working", "", false, "Proj/working/Acme", R, false},
|
||||
{"anon: NO create working", "", false, "Proj/working/Acme", C, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := canDo(t, cfg, tc.who, tc.elev, tc.dir, tc.action)
|
||||
if got != tc.want {
|
||||
t.Errorf("%s — canDo(%q, elevated=%v, %s, %q) = %v, want %v",
|
||||
tc.note, tc.who, tc.elev, tc.dir, tc.action, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -120,21 +120,22 @@ func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL str
|
|||
// caller tags .zddc writes that way). The handler does NOT make
|
||||
// admin/elevation decisions of its own — one bypass site, one helper.
|
||||
func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request, absPath, urlPath, action string) bool {
|
||||
probe := filepath.Dir(absPath)
|
||||
for {
|
||||
info, err := os.Stat(probe)
|
||||
if err == nil && info.IsDir() {
|
||||
break
|
||||
}
|
||||
if probe == cfg.Root || !strings.HasPrefix(probe, cfg.Root+string(filepath.Separator)) {
|
||||
probe = cfg.Root
|
||||
break
|
||||
}
|
||||
probe = filepath.Dir(probe)
|
||||
// Evaluate the cascade at the target's LOGICAL parent — NOT the nearest
|
||||
// on-disk ancestor. EffectivePolicy is virtual-path-aware: the embedded
|
||||
// paths: cascade resolves per-folder behaviour for directories that don't
|
||||
// exist on disk yet. A create deep under a not-yet-materialised canonical
|
||||
// path — e.g. mkdir working/<party>/<name> when working/<party>/ has never
|
||||
// been created — must see the working/ grant (document_controller rwcda,
|
||||
// project_team cr). Walking up to the nearest existing dir would instead
|
||||
// land on the shallower project-level grant (document_controller rw, no c)
|
||||
// and wrongly deny create.
|
||||
dir := filepath.Dir(absPath)
|
||||
if dir != cfg.Root && !strings.HasPrefix(dir, cfg.Root+string(filepath.Separator)) {
|
||||
dir = cfg.Root
|
||||
}
|
||||
|
||||
p := PrincipalFromContext(r)
|
||||
chain, err := zddc.EffectivePolicy(cfg.Root, probe)
|
||||
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
|
||||
if err != nil {
|
||||
slog.Warn("file API ACL chain error", "path", absPath, "err", err)
|
||||
}
|
||||
|
|
@ -806,7 +807,7 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Auto-ownership for the newly-created directory. The .zddc
|
||||
// cascade's `auto_own:` flag (see defaults.zddc.yaml) drives this,
|
||||
// cascade's `auto_own:` flag (see internal/zddc/defaults/) drives this,
|
||||
// same as EnsureCanonicalAncestors. A creator-owned .zddc lands
|
||||
// inside abs when:
|
||||
// - abs itself is declared auto_own (e.g. an explicit mkdir of
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ func serveProfileWhoami(cfg config.Config, email string, w http.ResponseWriter,
|
|||
URL string `json:"url"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
}
|
||||
writeJSON(w, response{
|
||||
resp := response{
|
||||
ConfiguredEmailHeader: cfg.EmailHeader,
|
||||
ObservedEmail: r.Header.Get(cfg.EmailHeader),
|
||||
ResolvedEmail: email,
|
||||
|
|
@ -338,7 +338,19 @@ func serveProfileWhoami(cfg config.Config, email string, w http.ResponseWriter,
|
|||
Method: r.Method,
|
||||
URL: r.URL.String(),
|
||||
Headers: headers,
|
||||
})
|
||||
}
|
||||
rows := []map[string]interface{}{
|
||||
kvRow("Configured email header", resp.ConfiguredEmailHeader),
|
||||
kvRow("Observed email (at that header)", resp.ObservedEmail),
|
||||
kvRow("Resolved email", resp.ResolvedEmail),
|
||||
kvRow("Remote addr", resp.RemoteAddr),
|
||||
kvRow("Method", resp.Method),
|
||||
kvRow("URL", resp.URL),
|
||||
}
|
||||
for _, k := range keys {
|
||||
rows = append(rows, kvRow("header: "+k, strings.Join(headers[k], ", ")))
|
||||
}
|
||||
serveDiagTable(w, r, "Whoami", "How the server sees this request (identity + headers).", kvColumns, rows, resp)
|
||||
}
|
||||
|
||||
// serveProfileConfig dumps the parsed Config. TLS cert/key paths are echoed,
|
||||
|
|
@ -355,7 +367,7 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques
|
|||
EmailHeader string `json:"email_header"`
|
||||
CORSOrigins []string `json:"cors_origins"`
|
||||
}
|
||||
writeJSON(w, response{
|
||||
resp := response{
|
||||
Root: cfg.Root,
|
||||
Addr: cfg.Addr,
|
||||
TLSCert: cfg.TLSCert,
|
||||
|
|
@ -365,19 +377,70 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques
|
|||
IndexPath: cfg.IndexPath,
|
||||
EmailHeader: cfg.EmailHeader,
|
||||
CORSOrigins: cfg.CORSOrigins,
|
||||
})
|
||||
}
|
||||
rows := []map[string]interface{}{
|
||||
kvRow("Root", resp.Root),
|
||||
kvRow("Addr", resp.Addr),
|
||||
kvRow("TLS cert", resp.TLSCert),
|
||||
kvRow("TLS key", resp.TLSKey),
|
||||
kvRow("TLS mode", resp.TLSMode),
|
||||
kvRow("Log level", resp.LogLevel),
|
||||
kvRow("Index path", resp.IndexPath),
|
||||
kvRow("Email header", resp.EmailHeader),
|
||||
kvRow("CORS origins", strings.Join(resp.CORSOrigins, ", ")),
|
||||
}
|
||||
serveDiagTable(w, r, "Server config", "Effective server configuration.", kvColumns, rows, resp)
|
||||
}
|
||||
|
||||
// serveProfileLogs returns the ring buffer's current contents. Optional query
|
||||
// params: level=debug|info|warn|error and since=<RFC3339>.
|
||||
func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) {
|
||||
if ring == nil {
|
||||
writeJSON(w, []LogEntry{})
|
||||
// serveDiagTable renders an admin-diagnostic collection through the shared
|
||||
// tables engine (header chrome + sortable/filterable columns) for browsers,
|
||||
// while keeping the raw JSON for scripted callers — content-negotiated on
|
||||
// Accept. Read-only; no apiActions. rawJSON is the existing JSON body, so the
|
||||
// machine contract is unchanged. The profile page links to these endpoints,
|
||||
// so a browser click lands on a real page, not raw JSON.
|
||||
func serveDiagTable(w http.ResponseWriter, r *http.Request, title, desc string, columns, rows []map[string]interface{}, rawJSON interface{}) {
|
||||
if !strings.Contains(r.Header.Get("Accept"), "text/html") || len(EmbeddedTablesHTML()) == 0 {
|
||||
writeJSON(w, rawJSON)
|
||||
return
|
||||
}
|
||||
injected, err := injectTableContextObj(EmbeddedTablesHTML(), map[string]interface{}{
|
||||
"title": title, "description": desc, "addable": false, "readOnly": true,
|
||||
"columns": columns, "rows": rows,
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, rawJSON)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(injected)
|
||||
}
|
||||
|
||||
entries := ring.Snapshot()
|
||||
func diagCol(field, title, width string) map[string]interface{} {
|
||||
c := map[string]interface{}{"field": field, "title": title}
|
||||
if width != "" {
|
||||
c["width"] = width
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// kvRow / kvColumns render a record as a two-column Field/Value table.
|
||||
func kvRow(field string, value interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{"editable": false, "data": map[string]interface{}{"field": field, "value": fmt.Sprintf("%v", value)}}
|
||||
}
|
||||
|
||||
var kvColumns = []map[string]interface{}{
|
||||
diagCol("field", "Field", "18em"),
|
||||
diagCol("value", "Value", ""),
|
||||
}
|
||||
|
||||
func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) {
|
||||
entries := []LogEntry{}
|
||||
if ring != nil {
|
||||
entries = ring.Snapshot()
|
||||
}
|
||||
if levelStr := r.URL.Query().Get("level"); levelStr != "" {
|
||||
min := levelRank(levelStr)
|
||||
out := entries[:0]
|
||||
|
|
@ -388,7 +451,6 @@ func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
entries = out
|
||||
}
|
||||
|
||||
if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
|
||||
if since, err := time.Parse(time.RFC3339, sinceStr); err == nil {
|
||||
out := entries[:0]
|
||||
|
|
@ -401,7 +463,29 @@ func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
writeJSON(w, entries)
|
||||
rows := make([]map[string]interface{}, 0, len(entries))
|
||||
for i := len(entries) - 1; i >= 0; i-- { // newest first
|
||||
e := entries[i]
|
||||
detail := ""
|
||||
if len(e.Attrs) > 0 {
|
||||
if b, err := json.Marshal(e.Attrs); err == nil {
|
||||
detail = string(b)
|
||||
}
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{"editable": false, "data": map[string]interface{}{
|
||||
"time": e.Time.Format("2006-01-02 15:04:05"),
|
||||
"level": e.Level,
|
||||
"message": e.Message,
|
||||
"detail": detail,
|
||||
}})
|
||||
}
|
||||
serveDiagTable(w, r, "Server logs", "Recent server log entries (newest first).",
|
||||
[]map[string]interface{}{
|
||||
diagCol("time", "Time", "13em"),
|
||||
diagCol("level", "Level", "6em"),
|
||||
diagCol("message", "Message", ""),
|
||||
diagCol("detail", "Detail", ""),
|
||||
}, rows, entries)
|
||||
}
|
||||
|
||||
func levelRank(s string) int {
|
||||
|
|
|
|||
|
|
@ -235,32 +235,6 @@ func TestServeProfileLogsLevelFilter(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// stripTemplates removes every <template ...>...</template> block from the
|
||||
// HTML body so substring assertions check only ACTIVE markup — i.e. live
|
||||
// DOM content the user (and their browser) actually sees, as opposed to
|
||||
// inert content that JS may clone in based on a later access fetch.
|
||||
//
|
||||
// Naive but sufficient for the controlled output of profileTemplate (the
|
||||
// template tags are unnested and well-formed). If the page ever grows
|
||||
// nested templates, swap this for an html.Tokenizer-based pass.
|
||||
func stripTemplates(body string) string {
|
||||
var b strings.Builder
|
||||
for {
|
||||
i := strings.Index(body, "<template")
|
||||
if i < 0 {
|
||||
b.WriteString(body)
|
||||
return b.String()
|
||||
}
|
||||
b.WriteString(body[:i])
|
||||
j := strings.Index(body[i:], "</template>")
|
||||
if j < 0 {
|
||||
// Unterminated <template> — bail; whatever's left is suspect.
|
||||
return b.String()
|
||||
}
|
||||
body = body[i+j+len("</template>"):]
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeProfileHTMLLayered pins the page-render contract after the
|
||||
// lazy-load refactor:
|
||||
//
|
||||
|
|
@ -306,85 +280,53 @@ func TestServeProfileHTMLLayered(t *testing.T) {
|
|||
return rec.Body.String()
|
||||
}
|
||||
|
||||
// Anonymous: identity says "Not signed in", no live admin markup, no
|
||||
// diagnostics. The <template> still ships inertly so any caller could
|
||||
// hydrate it after a successful /access fetch — but a non-admin's
|
||||
// /access response carries empty AdminSubtrees and the JS skips
|
||||
// instantiation. The active-markup check below proves the live DOM is
|
||||
// admin-clean regardless.
|
||||
// The page now renders through the shared tables engine with a server-
|
||||
// injected #table-context (no bespoke scaffolds). The per-role contract:
|
||||
// the context must never NAME a capability the caller lacks — super-admin
|
||||
// diagnostics (config/logs/whoami) appear only for a super-admin, so a
|
||||
// non-admin's bytes can't even reference them.
|
||||
diag := ProfilePathPrefix + "/config"
|
||||
|
||||
// Anonymous: "Not signed in" identity, no diagnostics.
|
||||
anon := render("")
|
||||
if !strings.Contains(anon, "Not signed in") {
|
||||
t.Errorf("anonymous body missing 'Not signed in'")
|
||||
}
|
||||
anonActive := stripTemplates(anon)
|
||||
for _, marker := range []string{
|
||||
`<form id="cp-form"`,
|
||||
`id="diag-config"`,
|
||||
`id="diag-logs"`,
|
||||
`id="diag-whoami"`,
|
||||
"Server config",
|
||||
} {
|
||||
if strings.Contains(anonActive, marker) {
|
||||
t.Errorf("anonymous active markup unexpectedly contains admin marker %q", marker)
|
||||
}
|
||||
if !strings.Contains(anon, `id="table-context"`) {
|
||||
t.Errorf("profile page not rendered via the tables engine")
|
||||
}
|
||||
// Inert <template> SHOULD ship — admins (and only admins) hydrate it.
|
||||
if !strings.Contains(anon, `<template id="tmpl-subtree-admin">`) {
|
||||
t.Errorf("anonymous body missing inert subtree-admin <template>")
|
||||
if strings.Contains(anon, diag) {
|
||||
t.Errorf("anonymous body leaks super-admin diagnostics (%q)", diag)
|
||||
}
|
||||
|
||||
// Non-admin (carol): email shown, no diagnostics.
|
||||
nonAdmin := render("carol@example.com")
|
||||
if !strings.Contains(nonAdmin, "carol@example.com") {
|
||||
t.Errorf("non-admin body missing email")
|
||||
}
|
||||
nonAdminActive := stripTemplates(nonAdmin)
|
||||
for _, marker := range []string{
|
||||
`<form id="cp-form"`,
|
||||
`id="diag-config"`,
|
||||
"Server config",
|
||||
} {
|
||||
if strings.Contains(nonAdminActive, marker) {
|
||||
t.Errorf("non-admin active markup unexpectedly contains admin marker %q", marker)
|
||||
}
|
||||
if strings.Contains(nonAdmin, diag) {
|
||||
t.Errorf("non-admin body leaks super-admin diagnostics")
|
||||
}
|
||||
|
||||
// Subtree-admin (bob) gets the same shell as a non-admin — the
|
||||
// scaffold lives in the <template> and JS hydrates it after fetching
|
||||
// /.profile/access. The server-side render no longer differentiates
|
||||
// these two roles, so its byte-output should match a non-admin's.
|
||||
// Subtree-admin (bob): administers projects/, but is NOT a root super-
|
||||
// admin — still no diagnostics.
|
||||
subtree := render("bob@example.com")
|
||||
subtreeActive := stripTemplates(subtree)
|
||||
for _, marker := range []string{
|
||||
`<form id="cp-form"`,
|
||||
`id="diag-config"`,
|
||||
"Server config",
|
||||
} {
|
||||
if strings.Contains(subtreeActive, marker) {
|
||||
t.Errorf("subtree-admin active markup unexpectedly contains admin marker %q (these are JS-hydrated)", marker)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(subtree, `<template id="tmpl-subtree-admin">`) {
|
||||
t.Errorf("subtree-admin body missing the <template> the IIFE will hydrate")
|
||||
if strings.Contains(subtree, diag) {
|
||||
t.Errorf("subtree-admin body leaks super-admin diagnostics")
|
||||
}
|
||||
|
||||
// Super-admin: diagnostics scaffold is rendered inline (cheap to
|
||||
// gate), AND the subtree-admin <template> still ships for the IIFE to
|
||||
// hydrate Editable + Create sections.
|
||||
// Super-admin (alice): diagnostics are discoverable as rows linking to
|
||||
// the (unchanged) endpoints.
|
||||
super := render("alice@example.com")
|
||||
superActive := stripTemplates(super)
|
||||
for _, marker := range []string{
|
||||
"Server config",
|
||||
`id="diag-config"`,
|
||||
`id="diag-logs"`,
|
||||
`id="diag-whoami"`,
|
||||
for _, link := range []string{
|
||||
ProfilePathPrefix + "/config",
|
||||
ProfilePathPrefix + "/logs",
|
||||
ProfilePathPrefix + "/whoami",
|
||||
} {
|
||||
if !strings.Contains(superActive, marker) {
|
||||
t.Errorf("super-admin active markup missing %q", marker)
|
||||
if !strings.Contains(super, link) {
|
||||
t.Errorf("super-admin profile missing diagnostic link %q", link)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(super, `<template id="tmpl-subtree-admin">`) {
|
||||
t.Errorf("super-admin body missing subtree-admin <template> (still needs to hydrate Editable + Create)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeProfileAccessJSON(t *testing.T) {
|
||||
|
|
@ -553,13 +495,15 @@ acl:
|
|||
t.Errorf("alice PathCanElevateGrant = %q, want empty (no admin grant on chain)", alice.PathCanElevateGrant)
|
||||
}
|
||||
|
||||
// Un-elevated admin: bypass not active, so explicit verbs are
|
||||
// whatever ACL granted (here: nothing — admin@ has no permissions
|
||||
// entry, only an admins: entry). PathCanElevateGrant tells the
|
||||
// client "elevation would unlock rwcda".
|
||||
// Un-elevated admin: the WORM/destructive bypass is not active, but
|
||||
// config-edit is a STANDING permission — being in the admins: cascade
|
||||
// grants `a` (edit .zddc/.zddc.zip/roles) without elevating. So the
|
||||
// explicit verbs are exactly "a" even though admin@ has no acl
|
||||
// permissions entry. PathCanElevateGrant tells the client "elevation
|
||||
// would unlock the rest (rwcda)".
|
||||
adminUn := fetch("admin@example.com", false)
|
||||
if adminUn.PathVerbs != "" {
|
||||
t.Errorf("un-elevated admin PathVerbs = %q, want empty (no explicit grant)", adminUn.PathVerbs)
|
||||
if adminUn.PathVerbs != "a" {
|
||||
t.Errorf("un-elevated admin PathVerbs = %q, want \"a\" (standing config-edit)", adminUn.PathVerbs)
|
||||
}
|
||||
if adminUn.PathIsAdmin {
|
||||
t.Errorf("un-elevated admin PathIsAdmin = true, want false")
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package handler
|
|||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
|
|
@ -41,6 +42,24 @@ func serveProfilePage(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
|||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
// Render "Effective access" (projects + admin subtrees) + Create project
|
||||
// through the shared tables engine — header chrome + declarative columns,
|
||||
// no bespoke page. The redundant/niche sections of the old page are
|
||||
// dropped: theme (now the header's theme button), the localStorage tool,
|
||||
// and the "editable .zddc" links (those files are now standing-editable in
|
||||
// browse). Falls back to the legacy template if the tables renderer isn't
|
||||
// built into this binary.
|
||||
tablesHTML := EmbeddedTablesHTML()
|
||||
if len(tablesHTML) > 0 {
|
||||
if injected, err := injectTableContextObj(tablesHTML, buildProfileTableContext(cfg, r)); err == nil {
|
||||
_, _ = w.Write(injected)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
email := EmailFromContext(r)
|
||||
view := profileView{
|
||||
Email: email,
|
||||
|
|
@ -50,13 +69,98 @@ func serveProfilePage(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
|||
AssetsPathPrefix: profileAssetsPathPrefix,
|
||||
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := profileTemplate.Execute(w, view); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// buildProfileTableContext assembles the #table-context for the profile page:
|
||||
// the caller's accessible scopes (projects + admin subtrees) as clickable
|
||||
// rows, identity in the description, and an apiActions block wiring "+ New
|
||||
// project" to POST /.profile/projects (only when the caller can create one).
|
||||
func buildProfileTableContext(cfg config.Config, r *http.Request) map[string]interface{} {
|
||||
view := enumerateAccess(r.Context(), DeciderFromContext(r), cfg, PrincipalFromContext(r), "")
|
||||
// Clicking a project/subtree row opens its .zddc INFO FORM (title, roles,
|
||||
// admins, …) in the browse editor — not the project's files. The browse
|
||||
// ?file=.zddc deep link selects + previews that dir's .zddc, which renders
|
||||
// as the schema-driven form (real or a virtual placeholder). dir_tool at
|
||||
// these paths is browse, so the trailing-slash URL loads the shell.
|
||||
zddcFormURL := func(dirURL string) string {
|
||||
if !strings.HasSuffix(dirURL, "/") {
|
||||
dirURL += "/"
|
||||
}
|
||||
return dirURL + "?file=.zddc"
|
||||
}
|
||||
rows := []map[string]interface{}{}
|
||||
for _, proj := range view.Projects {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"url": zddcFormURL(proj.URL),
|
||||
"editable": false,
|
||||
"data": map[string]interface{}{"name": proj.Name, "title": proj.Title, "kind": "project"},
|
||||
})
|
||||
}
|
||||
for _, sub := range view.AdminSubtrees {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"url": zddcFormURL(sub.Path),
|
||||
"editable": false,
|
||||
"data": map[string]interface{}{"name": sub.Path, "title": sub.Title, "kind": "admin"},
|
||||
})
|
||||
}
|
||||
// Super-admin diagnostics: keep config/logs/whoami discoverable as rows
|
||||
// (the endpoints are unchanged; only the bespoke links moved here). Gated
|
||||
// on IsSuperAdmin so a non-admin's context never names them.
|
||||
if view.IsSuperAdmin {
|
||||
for _, d := range []struct{ name, url string }{
|
||||
{"Server config", ProfilePathPrefix + "/config"},
|
||||
{"Server logs", ProfilePathPrefix + "/logs"},
|
||||
{"Whoami (request headers)", ProfilePathPrefix + "/whoami"},
|
||||
} {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"url": d.url,
|
||||
"editable": false,
|
||||
"data": map[string]interface{}{"name": d.name, "title": "", "kind": "server"},
|
||||
})
|
||||
}
|
||||
}
|
||||
desc := "Signed in as " + view.Email
|
||||
if view.Email == "" {
|
||||
desc = "Not signed in — the server reads identity from the " + cfg.EmailHeader + " header."
|
||||
} else if view.IsSuperAdmin {
|
||||
desc += " · super admin"
|
||||
}
|
||||
col := func(field, title, width string) map[string]interface{} {
|
||||
c := map[string]interface{}{"field": field, "title": title}
|
||||
if width != "" {
|
||||
c["width"] = width
|
||||
}
|
||||
return c
|
||||
}
|
||||
apiActions := map[string]interface{}{"rowNav": true}
|
||||
if view.CanCreateProject {
|
||||
apiActions["create"] = map[string]interface{}{
|
||||
"url": ProfilePathPrefix + "/projects",
|
||||
"title": "New project",
|
||||
"fixed": map[string]interface{}{"parent": "/"},
|
||||
"fields": []map[string]interface{}{
|
||||
{"name": "name", "label": "Folder name", "placeholder": "e.g. Site-3", "required": true},
|
||||
{"name": "title", "label": "Title (optional)"},
|
||||
},
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"title": "Profile",
|
||||
"description": desc,
|
||||
"addable": false,
|
||||
"columns": []map[string]interface{}{
|
||||
col("name", "Project", ""),
|
||||
col("title", "Title", ""),
|
||||
col("kind", "Type", "8em"),
|
||||
},
|
||||
"rows": rows,
|
||||
"apiActions": apiActions,
|
||||
}
|
||||
}
|
||||
|
||||
// profileTemplate is the html/template for the profile page. The shell is
|
||||
// rendered server-side from cheap-only data (identity + IsSuperAdmin); the
|
||||
// expensive bits (visible projects, admin subtrees, editable .zddc files,
|
||||
|
|
@ -199,12 +303,9 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
|||
<template id="tmpl-create-project">
|
||||
<section class="card">
|
||||
<h2>Create new project folder</h2>
|
||||
<p class="help">Creates a directory under the chosen parent. Your email is added to admins automatically so you administer the new project; you can also fill title / ACL / additional admins below.</p>
|
||||
<p class="help">Creates a top-level project folder. Your email is recorded as the project's creator and added to its admins automatically. Assign members to the project roles below — one email (or role pattern) per row.</p>
|
||||
<div id="cp-ok" class="ok-banner" hidden>Created.</div>
|
||||
<form id="cp-form" autocomplete="off">
|
||||
<label>Parent
|
||||
<select name="parent" id="cp-parent"></select>
|
||||
</label>
|
||||
<label>Name
|
||||
<input type="text" name="name" id="cp-name" maxlength="64" placeholder="e.g. Site-3" required>
|
||||
<span class="err" id="cp-name-err"></span>
|
||||
|
|
@ -212,13 +313,26 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
|||
<label>Title (optional)
|
||||
<input type="text" name="title" id="cp-title" maxlength="200">
|
||||
</label>
|
||||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL — Permissions (optional)</h3>
|
||||
<p class="help" style="margin: 0 0 .3rem;">Pattern (email or role) → verbs (drawn from <code>r w c d a</code>). Empty verbs = explicit deny.</p>
|
||||
<div class="list" data-field="acl.permissions"></div>
|
||||
<button type="button" class="add" data-target="acl.permissions">+ Add permission</button>
|
||||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Additional admins (optional)</h3>
|
||||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Admins</h3>
|
||||
<p class="help" style="margin: 0 0 .3rem;">Full control of the project (you are already an admin).</p>
|
||||
<div class="list" data-field="admins"></div>
|
||||
<button type="button" class="add" data-target="admins">+ Add admin</button>
|
||||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Document controllers</h3>
|
||||
<p class="help" style="margin: 0 0 .3rem;">Manage filing & records — read / write / create / delete.</p>
|
||||
<div class="list" data-field="document_controllers"></div>
|
||||
<button type="button" class="add" data-target="document_controllers">+ Add document controller</button>
|
||||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Project team</h3>
|
||||
<p class="help" style="margin: 0 0 .3rem;">Contribute documents — read / write / create.</p>
|
||||
<div class="list" data-field="project_team"></div>
|
||||
<button type="button" class="add" data-target="project_team">+ Add team member</button>
|
||||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Guests</h3>
|
||||
<p class="help" style="margin: 0 0 .3rem;">Read-only access.</p>
|
||||
<div class="list" data-field="guests"></div>
|
||||
<button type="button" class="add" data-target="guests">+ Add guest</button>
|
||||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Advanced — ACL permissions (optional)</h3>
|
||||
<p class="help" style="margin: 0 0 .3rem;">Pattern (email or role) → verbs (drawn from <code>r w c d a</code>). Empty verbs = explicit deny. Overrides the role grants above for the same pattern.</p>
|
||||
<div class="list" data-field="acl.permissions"></div>
|
||||
<button type="button" class="add" data-target="acl.permissions">+ Add permission</button>
|
||||
<div style="margin-top: 1rem;">
|
||||
<button type="submit" class="primary">Create</button>
|
||||
</div>
|
||||
|
|
@ -417,26 +531,6 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
|||
host.innerHTML = html;
|
||||
}
|
||||
|
||||
function populateParentChoices(adminSubtrees) {
|
||||
var sel = document.getElementById("cp-parent");
|
||||
if (!sel) return;
|
||||
sel.innerHTML = "";
|
||||
// Root is offered whenever the caller can create projects there —
|
||||
// super-admin (full bypass) or cascade-granted "c" at the root.
|
||||
// The server's can_create_project flag means both, since it runs
|
||||
// the same decider gate the endpoint uses.
|
||||
if (isSuper || canCreateProject) {
|
||||
var optRoot = document.createElement("option");
|
||||
optRoot.value = "/"; optRoot.textContent = "/ (root)";
|
||||
sel.appendChild(optRoot);
|
||||
}
|
||||
(adminSubtrees || []).forEach(function(s) {
|
||||
var opt = document.createElement("option");
|
||||
opt.value = s.path; opt.textContent = s.path;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function rowFor(field) {
|
||||
var div = document.createElement("div"); div.className = "row";
|
||||
var input = document.createElement("input");
|
||||
|
|
@ -493,14 +587,21 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
|||
document.getElementById("cp-ok").hidden = true;
|
||||
var permissions = collectPermissions();
|
||||
var admins = collectList("admins");
|
||||
var dcs = collectList("document_controllers");
|
||||
var team = collectList("project_team");
|
||||
var guests = collectList("guests");
|
||||
var title = document.getElementById("cp-title").value.trim();
|
||||
// Projects are always created at the deployment root (top level).
|
||||
var body = {
|
||||
parent: document.getElementById("cp-parent").value,
|
||||
parent: "/",
|
||||
name: document.getElementById("cp-name").value.trim()
|
||||
};
|
||||
if (title) body.title = title;
|
||||
if (Object.keys(permissions).length) body.acl = { permissions: permissions };
|
||||
if (admins.length) body.admins = admins;
|
||||
if (dcs.length) body.document_controllers = dcs;
|
||||
if (team.length) body.project_team = team;
|
||||
if (guests.length) body.guests = guests;
|
||||
fetch(prefix + "/projects", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||
|
|
@ -547,7 +648,6 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
|||
if (cpTmpl) {
|
||||
var cpSlot = document.getElementById("create-project-slot");
|
||||
cpSlot.appendChild(cpTmpl.content.cloneNode(true));
|
||||
populateParentChoices(view.admin_subtrees || []);
|
||||
wireCreateProjectForm();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
|
|
@ -29,6 +30,43 @@ type projectCreateRequest struct {
|
|||
Title string `json:"title,omitempty"`
|
||||
ACL *zddc.ACLRules `json:"acl,omitempty"`
|
||||
Admins []string `json:"admins,omitempty"`
|
||||
// Role groups: member email lists for the conventional project roles.
|
||||
// Each non-empty list becomes a roles:<name> MEMBERSHIP entry. Verbs are
|
||||
// NOT set here — the embedded defaults grant each role its per-folder
|
||||
// permissions (read across the project; create in the workspaces; WORM
|
||||
// archive; rwc on mdl/rsk for the team). The "Guests" UI field maps to
|
||||
// the read-only `observer` role used by those defaults.
|
||||
DocumentControllers []string `json:"document_controllers,omitempty"`
|
||||
ProjectTeam []string `json:"project_team,omitempty"`
|
||||
Guests []string `json:"guests,omitempty"`
|
||||
}
|
||||
|
||||
// projectRoleGroups maps each create-dialog member list to the canonical role
|
||||
// it populates. Membership only — verbs live in the embedded defaults, which
|
||||
// reference these exact role names. Stable order for deterministic output.
|
||||
var projectRoleGroups = []struct {
|
||||
role string
|
||||
pick func(projectCreateRequest) []string
|
||||
}{
|
||||
{"document_controller", func(r projectCreateRequest) []string { return r.DocumentControllers }},
|
||||
{"project_team", func(r projectCreateRequest) []string { return r.ProjectTeam }},
|
||||
{"observer", func(r projectCreateRequest) []string { return r.Guests }},
|
||||
}
|
||||
|
||||
// dedupeStrings trims, drops empties, and removes duplicates (first-wins),
|
||||
// preserving order.
|
||||
func dedupeStrings(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// projectCreateResponse is the success payload.
|
||||
|
|
@ -108,21 +146,39 @@ func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *htt
|
|||
return
|
||||
}
|
||||
|
||||
// Always seed a starter .zddc — the creator becomes subtree admin of
|
||||
// their new project. Caller can also pass title / ACL / extra
|
||||
// admins on top.
|
||||
admins := req.Admins
|
||||
if len(admins) == 0 && p.Email != "" {
|
||||
admins = []string{p.Email}
|
||||
}
|
||||
// Always seed a starter .zddc. The creator administers their new project
|
||||
// and is RECORDED as its creator (created_by, audit). Caller can also
|
||||
// pass title / ACL / role groups / extra admins on top.
|
||||
var zf zddc.ZddcFile
|
||||
zf.Title = req.Title
|
||||
zf.CreatedBy = p.Email
|
||||
if req.ACL != nil {
|
||||
zf.ACL = *req.ACL
|
||||
}
|
||||
zf.Admins = admins
|
||||
wantsZddc := len(zf.Admins) > 0 || zf.Title != "" ||
|
||||
(req.ACL != nil && len(req.ACL.Permissions) > 0)
|
||||
// Creator is always an admin (deduped, first), then any extra admins.
|
||||
zf.Admins = dedupeStrings(append([]string{p.Email}, req.Admins...))
|
||||
|
||||
// Role groups → role MEMBERSHIP at the project root. No verbs are written
|
||||
// here: the embedded defaults already grant document_controller /
|
||||
// project_team / observer their per-folder permissions, and membership
|
||||
// unions across the cascade — so naming members here is enough. (An
|
||||
// operator can still add explicit acl.permissions via the advanced field.)
|
||||
for _, g := range projectRoleGroups {
|
||||
members := dedupeStrings(g.pick(req))
|
||||
if len(members) == 0 {
|
||||
continue
|
||||
}
|
||||
if zf.Roles == nil {
|
||||
zf.Roles = map[string]zddc.Role{}
|
||||
}
|
||||
zf.Roles[g.role] = zddc.Role{Members: members}
|
||||
}
|
||||
|
||||
// We always record the creator, so a .zddc is essentially always
|
||||
// written; the guard only skips the rare anonymous-creator case with
|
||||
// no other content.
|
||||
wantsZddc := zf.CreatedBy != "" || len(zf.Admins) > 0 || zf.Title != "" ||
|
||||
len(zf.Roles) > 0 || len(zf.ACL.Permissions) > 0
|
||||
if wantsZddc {
|
||||
if errs := zddc.ValidateFile(zf); len(errs) > 0 {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
|
|
|||
|
|
@ -119,3 +119,73 @@ func TestProjectCreate_DuplicateNameRejected(t *testing.T) {
|
|||
t.Errorf("status=%d, want 409", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The creator is recorded in created_by (+ made admin), and the role-group
|
||||
// member lists become roles{} MEMBERSHIP — with NO root verb grants (verbs
|
||||
// come from the embedded per-folder defaults). "guests" maps to `observer`.
|
||||
func TestProjectCreate_RecordsCreatorAndRoles(t *testing.T) {
|
||||
cfg, root := projectCreateFixture(t)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"parent": "/",
|
||||
"name": "RoleProj",
|
||||
"document_controllers": []string{"dc@example.com"},
|
||||
"project_team": []string{"t1@example.com", "t2@example.com"},
|
||||
"guests": []string{"guest@example.com"},
|
||||
})
|
||||
rec := doProjectCreate(cfg, "alice@example.com", false, body)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
zf, err := zddc.ParseFile(filepath.Join(root, "RoleProj", ".zddc"))
|
||||
if err != nil {
|
||||
t.Fatalf("read new .zddc: %v", err)
|
||||
}
|
||||
if zf.CreatedBy != "alice@example.com" {
|
||||
t.Errorf("CreatedBy=%q, want alice@example.com", zf.CreatedBy)
|
||||
}
|
||||
if len(zf.Admins) != 1 || zf.Admins[0] != "alice@example.com" {
|
||||
t.Errorf("Admins=%v, want [alice@example.com]", zf.Admins)
|
||||
}
|
||||
if r, ok := zf.Roles["document_controller"]; !ok || len(r.Members) != 1 || r.Members[0] != "dc@example.com" {
|
||||
t.Errorf("document_controller role=%v", zf.Roles["document_controller"])
|
||||
}
|
||||
if r, ok := zf.Roles["project_team"]; !ok || len(r.Members) != 2 {
|
||||
t.Errorf("project_team role=%v", zf.Roles["project_team"])
|
||||
}
|
||||
// "guests" populates the read-only observer role used by the defaults.
|
||||
if r, ok := zf.Roles["observer"]; !ok || len(r.Members) != 1 || r.Members[0] != "guest@example.com" {
|
||||
t.Errorf("observer role=%v", zf.Roles["observer"])
|
||||
}
|
||||
if _, ok := zf.Roles["guest"]; ok {
|
||||
t.Errorf("should not create a 'guest' role; it maps to observer")
|
||||
}
|
||||
// No verbs seeded at the project root — verbs come from the cascade.
|
||||
if len(zf.ACL.Permissions) != 0 {
|
||||
t.Errorf("project root should carry no acl.permissions, got %v", zf.ACL.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
// The advanced acl.permissions field still passes through verbatim (the
|
||||
// escape hatch for operators who want explicit project-root grants).
|
||||
func TestProjectCreate_AdvancedACLPassesThrough(t *testing.T) {
|
||||
cfg, root := projectCreateFixture(t)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"parent": "/",
|
||||
"name": "OverrideProj",
|
||||
"project_team": []string{"t@example.com"},
|
||||
"acl": map[string]any{"permissions": map[string]string{"*@vendor.com": "r"}},
|
||||
})
|
||||
rec := doProjectCreate(cfg, "alice@example.com", false, body)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
zf, _ := zddc.ParseFile(filepath.Join(root, "OverrideProj", ".zddc"))
|
||||
if zf.ACL.Permissions["*@vendor.com"] != "r" {
|
||||
t.Errorf("advanced ACL should pass through: got %q want r", zf.ACL.Permissions["*@vendor.com"])
|
||||
}
|
||||
if _, ok := zf.Roles["project_team"]; !ok {
|
||||
t.Errorf("project_team role missing alongside explicit ACL")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
zddc/internal/handler/schemahandler.go
Normal file
28
zddc/internal/handler/schemahandler.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// ZddcSchemaPath is the JSON endpoint serving the .zddc JSON Schema (the machine
|
||||
// grammar). The browse client + the .zddc form view fetch it to drive editing
|
||||
// (per-property x-zddc-tier marks structure vs option) and validation.
|
||||
const ZddcSchemaPath = "/.api/zddc-schema"
|
||||
|
||||
// ServeZddcSchema returns the embedded .zddc JSON Schema. Read-only, no auth —
|
||||
// it documents the policy grammar and leaks nothing. GET/HEAD only.
|
||||
func ServeZddcSchema(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/schema+json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "max-age=300")
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(zddc.ZddcSchemaBytes())
|
||||
}
|
||||
|
|
@ -258,7 +258,7 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW
|
|||
}
|
||||
|
||||
// Resolve the cascade rule at slotAbs to pick a composed filename.
|
||||
// The defaults.zddc.yaml records: entries declare a "*.yaml" rule
|
||||
// The internal/zddc/defaults/ records: entries declare a "*.yaml" rule
|
||||
// for both mdl/ and rsk/ folders with filename_format pointing at
|
||||
// body fields; for RSK, the rule also carries row_field +
|
||||
// row_scope_fields so the server can assign the next row sequence
|
||||
|
|
|
|||
|
|
@ -439,6 +439,31 @@ func injectTableContext(template, tableYAML, formYAML []byte) ([]byte, error) {
|
|||
return bytesReplace(template, needle, replacement), nil
|
||||
}
|
||||
|
||||
// injectTableContextObj writes a fully pre-assembled table context (title,
|
||||
// columns, rows, apiActions, …) into the `#table-context` placeholder, so the
|
||||
// client renders it as-is with no directory walk (context.js treats a context
|
||||
// carrying a columns[] array as authoritative). Used to render dynamic
|
||||
// server-side collections — e.g. the token list at /.tokens — through the same
|
||||
// tables engine + chrome as on-disk tables, instead of a bespoke page.
|
||||
func injectTableContextObj(template []byte, ctx interface{}) ([]byte, error) {
|
||||
js, err := json.Marshal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
js = []byte(strings.ReplaceAll(string(js), "</", "<\\/"))
|
||||
needle := []byte(`<script id="table-context" type="application/json">{}</script>`)
|
||||
if !bytesContains(template, needle) {
|
||||
return nil, errBundle("#table-context placeholder not found in template")
|
||||
}
|
||||
replacement := append([]byte(`<script id="table-context" type="application/json">`), js...)
|
||||
replacement = append(replacement, []byte(`</script>`)...)
|
||||
return bytesReplace(template, needle, replacement), nil
|
||||
}
|
||||
|
||||
// EmbeddedTablesHTML exposes the embedded tables renderer to sibling handlers
|
||||
// (e.g. the token page) that render a server-injected collection through it.
|
||||
func EmbeddedTablesHTML() []byte { return embeddedTablesHTML }
|
||||
|
||||
type errBundle string
|
||||
|
||||
func (e errBundle) Error() string { return string(e) }
|
||||
|
|
|
|||
|
|
@ -74,6 +74,20 @@
|
|||
/* Shape */
|
||||
--radius: 4px;
|
||||
|
||||
/* Spacing scale — referenced by the tables tool (tables/css/table.css).
|
||||
Were undefined (var() with no fallback → collapsed to 0), which left
|
||||
table cells unpadded and the table flush to the viewport edges. */
|
||||
--spacing-sm: 0.4rem;
|
||||
--spacing-md: 0.8rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
|
||||
/* Token aliases the tables tool references under --color-*/--radius-*
|
||||
names; map them to the canonical tokens (themed values flow through). */
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-border: var(--border);
|
||||
--color-bg-elevated: var(--bg-secondary);
|
||||
--radius-sm: var(--radius);
|
||||
|
||||
/* Typography. --font-display covers headings (Source Serif 4 — a refined
|
||||
transitional serif that reads as "engineering / document / serious"
|
||||
without being academic). --font is body UI text (IBM Plex Sans —
|
||||
|
|
@ -855,53 +869,10 @@ body.help-open .app-header {
|
|||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* 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/elevation.css — page-wide armed chrome for admin mode.
|
||||
The elevate CONTROL is the "Admin mode" item in the shared profile menu
|
||||
(shared/profile-menu.{js,css}); this file only styles the unmistakable
|
||||
"you are elevated" cues: the red viewport frame + the sticky banner. */
|
||||
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
easy to miss; these add an inescapable visual cue:
|
||||
|
|
@ -978,6 +949,118 @@ body.is-elevated::after {
|
|||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* shared/profile-menu.css — header account menu (upper-right).
|
||||
shared/profile-menu.js mounts a button into `.header-right` and toggles
|
||||
a dropdown with the signed-in email, Admin mode, Profile, Access tokens,
|
||||
and Sign out. Server mode only. */
|
||||
|
||||
.profile-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* The button: a small circular avatar showing the email initial. */
|
||||
.profile-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
}
|
||||
.profile-btn__avatar {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Armed (admin mode on): a red ring so the elevated state reads from the
|
||||
button even when the menu is closed — pairs with the page banner/frame. */
|
||||
.profile-btn--armed {
|
||||
box-shadow: 0 0 0 2px var(--danger, #dc3545);
|
||||
border-color: var(--danger, #dc3545);
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__panel {
|
||||
display: none;
|
||||
/* Fixed + JS-positioned from the button rect: an absolute panel gets
|
||||
trapped below the content layer by the app's stacking contexts, so
|
||||
anchor it to the viewport instead (profile-menu.js sets top/right). */
|
||||
position: fixed;
|
||||
min-width: 15rem;
|
||||
z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */
|
||||
background: var(--bg, #fff);
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: var(--radius, 6px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
|
||||
padding: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.profile-menu__panel.open { display: block; }
|
||||
|
||||
.profile-menu__id {
|
||||
padding: 0.35rem 0.55rem 0.45rem;
|
||||
}
|
||||
.profile-menu__email {
|
||||
font-weight: 600;
|
||||
color: var(--text, #222);
|
||||
word-break: break-all;
|
||||
}
|
||||
.profile-menu__role {
|
||||
margin-top: 0.1rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__sep {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0;
|
||||
background: var(--border, #eee);
|
||||
}
|
||||
|
||||
.profile-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: var(--radius, 4px);
|
||||
color: var(--text, #222);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.profile-menu__item:hover {
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.profile-menu__toggle { cursor: pointer; }
|
||||
.profile-menu__check {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger, #dc3545);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.profile-menu__toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.profile-menu__hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||
inherits the logo's box and adds a subtle hover/focus affordance
|
||||
so it reads as clickable without altering the logo's visual weight. */
|
||||
|
|
@ -1113,7 +1196,9 @@ body.is-elevated::after {
|
|||
/* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */
|
||||
|
||||
.table-main {
|
||||
padding: var(--spacing-md);
|
||||
/* Vertical breathing room + clear left/right gutters so the table isn't
|
||||
flush to the viewport edges. */
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
|
@ -1320,6 +1405,35 @@ body.is-elevated::after {
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── api-actions.js: create modal + per-row delete (e.g. /.tokens) ─────────── */
|
||||
.api-modal__overlay {
|
||||
position: fixed; inset: 0; z-index: 9500;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.api-modal {
|
||||
background: var(--bg, #fff); color: var(--text, #222);
|
||||
border: 1px solid var(--border, #ccc); border-radius: var(--radius, 6px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
padding: 1.1rem 1.2rem; width: min(28rem, 92vw);
|
||||
}
|
||||
.api-modal__title { margin: 0 0 .8rem; font-size: 1.1rem; }
|
||||
.api-modal__field { display: flex; flex-direction: column; gap: .25rem; margin-bottom: .7rem; font-size: .85rem; }
|
||||
.api-modal__field input {
|
||||
padding: .4rem .5rem; font: inherit;
|
||||
border: 1px solid var(--border, #ccc); border-radius: var(--radius, 4px);
|
||||
background: var(--bg, #fff); color: var(--text, #222);
|
||||
}
|
||||
.api-modal__actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: .8rem; }
|
||||
.api-modal__err { color: var(--danger, #b00020); font-size: .82rem; margin: .2rem 0; }
|
||||
.api-modal__secret-label { margin: 0 0 .5rem; font-size: .9rem; }
|
||||
.api-modal__secret {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem;
|
||||
word-break: break-all; padding: .6rem .7rem; border-radius: var(--radius, 4px);
|
||||
background: var(--bg-alt, #f3f4f6); border: 1px solid var(--border, #ccc);
|
||||
}
|
||||
.api-revoke { white-space: nowrap; margin-left: .6rem; float: right; }
|
||||
|
||||
/* form/ — ZDDC generic form renderer.
|
||||
Form-specific layout only; theme tokens (--primary, --bg, --text,
|
||||
--border, --bg-secondary, --text-muted, --font-mono, --radius) come
|
||||
|
|
@ -1534,16 +1648,10 @@ body.is-elevated::after {
|
|||
</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.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3</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>
|
||||
|
|
@ -2822,26 +2930,31 @@ body.is-elevated::after {
|
|||
}
|
||||
}());
|
||||
|
||||
// shared/elevation.js — admin elevation via URL toggle.
|
||||
// shared/elevation.js — admin elevation state machine.
|
||||
//
|
||||
// Sudo-style model: admins behave as normal users by default; elevating
|
||||
// the session turns on admin escape hatches (WORM bypass, .zddc edit
|
||||
// authority, profile admin scaffolds). State is carried in a
|
||||
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
|
||||
// → zddc.Principal{Elevated}.
|
||||
// the session turns on admin escape hatches (WORM bypass, recursive
|
||||
// delete, rearranging records, profile admin scaffolds — NOT config-edit,
|
||||
// which is standing). State is carried in a `zddc-elevate=1` cookie that
|
||||
// the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
|
||||
//
|
||||
// Toggle is by URL query param — `?admin=true` to arm, `?admin=false`
|
||||
// (or the red banner's "Drop admin" button) to drop — so it's reachable
|
||||
// from ANY zddc-server page, not just ones that render a header control.
|
||||
// The cookie is the sticky state: it persists across navigation for its
|
||||
// Max-Age window, so the param need not stay in the URL (we strip it).
|
||||
// Arming is gated on /.profile/access `can_elevate`, so only real admins
|
||||
// can set it; a non-admin's ?admin=true is a silent no-op.
|
||||
// This module owns the STATE (cookie, armed chrome/banner, ephemeral
|
||||
// lifecycle, the change event) + exposes setOn/setOff/isElevated. The
|
||||
// on-page elevate CONTROL lives in the shared profile menu
|
||||
// (shared/profile-menu.js) — an "Admin mode" item shown only to
|
||||
// can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
|
||||
// into any URL is also honoured (gated on can_elevate), for deep links /
|
||||
// scripting.
|
||||
//
|
||||
// Applying the cookie reloads to the cleaned URL so the server re-renders
|
||||
// under the new state (admin scaffolds in some tool HTML are server-
|
||||
// rendered, so a client-only flip wouldn't reach them). The red viewport
|
||||
// border + banner (applyArmedChrome) reflect the cookie on every load.
|
||||
// Admin mode is EPHEMERAL — scoped to the page you turned it on:
|
||||
// * the cookie is a SESSION cookie (no Max-Age), and
|
||||
// * we clear it on `pagehide`, so navigating away / closing the tab
|
||||
// drops admin (you re-arm deliberately on the next page).
|
||||
// Because of that we apply state IN PLACE (no reload — a reload's pagehide
|
||||
// would race the clear). SPAs that server-render elevation-dependent data
|
||||
// (e.g. browse's listing verbs) listen for the `zddc:elevationchange`
|
||||
// event we emit and re-fetch. The red viewport border + banner
|
||||
// (applyArmedChrome) reflect the cookie, kept in sync on every change.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
|
|
@ -2862,16 +2975,43 @@ body.is-elevated::after {
|
|||
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';
|
||||
// shapes. No Max-Age → a SESSION cookie: it dies with the tab
|
||||
// and, combined with the pagehide handler below, is cleared the
|
||||
// moment you leave the page. Admin powers never silently
|
||||
// outlive the page you armed them on.
|
||||
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
|
||||
} else {
|
||||
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
|
||||
}
|
||||
}
|
||||
|
||||
// emitChange notifies same-page listeners (SPAs that server-render
|
||||
// elevation-dependent data, e.g. browse's listing verbs / editor
|
||||
// affordances) so they can re-fetch without a full reload.
|
||||
function emitChange() {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('zddc:elevationchange', {
|
||||
detail: { elevated: isElevated() }
|
||||
}));
|
||||
} catch (_e) { /* CustomEvent unsupported — non-fatal */ }
|
||||
}
|
||||
|
||||
// setOn / setOff are the single funnel for every arm/drop path (the
|
||||
// profile menu's Admin mode item, the ?admin= URL param, the banner's
|
||||
// Drop button). Each flips the cookie, re-paints the armed chrome, and
|
||||
// emits the change — no reload. The profile menu listens for the change
|
||||
// event to keep its checkbox + armed indicator in sync.
|
||||
function setOn() {
|
||||
setElevated(true);
|
||||
applyArmedChrome(true);
|
||||
emitChange();
|
||||
}
|
||||
function setOff() {
|
||||
setElevated(false);
|
||||
applyArmedChrome(false);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var resp = await fetch('/.profile/access', {
|
||||
|
|
@ -2917,34 +3057,26 @@ body.is-elevated::after {
|
|||
return u.pathname + (qs ? '?' + qs : '') + u.hash;
|
||||
}
|
||||
|
||||
// handleAdminParam applies a ?admin= request. Returns true when a
|
||||
// navigation (reload) is underway so the caller can stop. Enabling is
|
||||
// gated on can_elevate — a non-admin who types ?admin=true just gets
|
||||
// the param stripped, never a misleading red border. Disabling is open
|
||||
// (anyone may drop a cookie they somehow hold).
|
||||
async function handleAdminParam() {
|
||||
// handleAdminParam applies a ?admin= request IN PLACE (no reload — see
|
||||
// the module header on why reloads would race the pagehide-clear).
|
||||
// Enabling is gated on can_elevate — a non-admin who types ?admin=true
|
||||
// just gets the param stripped, never a misleading red border.
|
||||
// Disabling is open (anyone may drop a cookie they somehow hold).
|
||||
// `access` (a prefetched /.profile/access, may be null) lets init reuse
|
||||
// its single fetch instead of issuing a second one.
|
||||
async function handleAdminParam(access) {
|
||||
var want = adminParam();
|
||||
if (want === null) return false;
|
||||
if (want === null) return;
|
||||
var clean = urlWithoutAdmin();
|
||||
if (want === isElevated()) {
|
||||
// Already in the requested state — just clean the URL, no reload.
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
return false;
|
||||
}
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
if (want === isElevated()) return; // already in the requested state
|
||||
if (want === true) {
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.can_elevate) {
|
||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||
return false;
|
||||
}
|
||||
setElevated(true);
|
||||
if (access === undefined) access = await fetchAccess();
|
||||
if (!access || !access.can_elevate) return; // silent no-op
|
||||
setOn();
|
||||
} else {
|
||||
setElevated(false);
|
||||
setOff();
|
||||
}
|
||||
// Navigate to the clean URL (a real load, so the server re-renders
|
||||
// under the new cookie) and replace history so Back is safe.
|
||||
window.location.replace(clean);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Page-wide affordances when elevation is active. The toggle alone
|
||||
|
|
@ -2975,10 +3107,7 @@ body.is-elevated::after {
|
|||
+ '</button>';
|
||||
document.body.insertBefore(banner, document.body.firstChild);
|
||||
var off = banner.querySelector('#elevation-banner-off');
|
||||
if (off) off.addEventListener('click', function () {
|
||||
setElevated(false);
|
||||
window.location.reload();
|
||||
});
|
||||
if (off) off.addEventListener('click', function () { setOff(); });
|
||||
}
|
||||
} else if (banner) {
|
||||
banner.parentNode.removeChild(banner);
|
||||
|
|
@ -2986,16 +3115,30 @@ body.is-elevated::after {
|
|||
}
|
||||
|
||||
async function init() {
|
||||
// Apply (or tear down) the red border + banner from the cookie on
|
||||
// every page load — admin mode is toggled by URL, but the armed
|
||||
// chrome must surface everywhere so the user can't accidentally
|
||||
// write through an elevated context on a page they didn't toggle.
|
||||
// file:// (offline FS-Access mode) has no server to elevate against.
|
||||
if (window.location.protocol === 'file:') return;
|
||||
|
||||
// Reflect the cookie's armed chrome on every load (a leftover from a
|
||||
// not-yet-fired pagehide, or an arrived-with ?admin link).
|
||||
applyArmedChrome(isElevated());
|
||||
|
||||
// Honour ?admin=true|false typed into any zddc-server URL. There's
|
||||
// no on-screen toggle anymore — the URL is the enable path and the
|
||||
// red banner's "Drop admin" button is the one-click disable.
|
||||
// Honour ?admin=true|false typed into any URL — handleAdminParam
|
||||
// fetches /.profile/access itself to gate arming on can_elevate. The
|
||||
// on-page elevate control lives in the shared profile menu
|
||||
// (shared/profile-menu.js), which calls setOn/setOff and listens for
|
||||
// zddc:elevationchange to keep its checkbox + armed ring in sync.
|
||||
await handleAdminParam();
|
||||
|
||||
// Admin mode is per-page: clear the cookie when the page goes away so
|
||||
// it never persists past a navigation.
|
||||
window.addEventListener('pagehide', function () {
|
||||
if (isElevated()) setElevated(false);
|
||||
});
|
||||
// bfcache can restore a page whose pagehide already cleared the
|
||||
// cookie — re-sync the armed chrome so chrome ≠ cookie can't happen.
|
||||
window.addEventListener('pageshow', function (e) {
|
||||
if (e.persisted) applyArmedChrome(isElevated());
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
|
@ -3004,7 +3147,178 @@ body.is-elevated::after {
|
|||
init();
|
||||
}
|
||||
|
||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||
window.zddc.elevation = {
|
||||
isElevated: isElevated,
|
||||
setElevated: setElevated,
|
||||
setOn: setOn,
|
||||
setOff: setOff
|
||||
};
|
||||
})();
|
||||
|
||||
// shared/profile-menu.js — account menu in the header's upper-right.
|
||||
//
|
||||
// Replaces the old floating elevation toggle. Admin mode is now one item in
|
||||
// this dropdown, alongside the signed-in email, Profile, and Access tokens.
|
||||
// Mounts into the tool header's `.header-right` cluster (every tool ships one)
|
||||
// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome
|
||||
// / ephemeral state machine stays in shared/elevation.js.
|
||||
//
|
||||
// No logout: authentication is the upstream proxy's concern (oauth2-proxy /
|
||||
// Authelia) — ZDDC owns no session, so it doesn't touch sign-out.
|
||||
//
|
||||
// Server mode only: it reads /.profile/access for the email + can_elevate.
|
||||
// On file:// (offline FS-Access mode) there's no server account, so nothing
|
||||
// renders.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.zddc) window.zddc = {};
|
||||
if (window.zddc.profileMenu) return;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var r = await fetch('/.profile/access', {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (_e) { return null; }
|
||||
}
|
||||
|
||||
var elevation = null;
|
||||
var panelEl = null, btnEl = null, adminInput = null;
|
||||
|
||||
function isElevated() {
|
||||
return !!(elevation && elevation.isElevated && elevation.isElevated());
|
||||
}
|
||||
|
||||
// Keep the button's armed ring + the menu checkbox in lockstep with the
|
||||
// elevation cookie (flipped here, by ?admin=, or by the banner's Drop).
|
||||
function syncArmed() {
|
||||
if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated());
|
||||
if (adminInput) adminInput.checked = isElevated();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (panelEl) panelEl.classList.remove('open');
|
||||
if (btnEl) btnEl.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
// The panel is position:fixed (to escape the app's stacking contexts), so
|
||||
// anchor it to the button rect — top just below it, right-aligned.
|
||||
function positionPanel() {
|
||||
var r = btnEl.getBoundingClientRect();
|
||||
panelEl.style.top = (r.bottom + 4) + 'px';
|
||||
panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
|
||||
panelEl.style.left = 'auto';
|
||||
}
|
||||
function toggleMenu() {
|
||||
if (!panelEl) return;
|
||||
var open = panelEl.classList.toggle('open');
|
||||
if (open) positionPanel();
|
||||
btnEl.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function linkItem(text, href) {
|
||||
var a = el('a', 'profile-menu__item', text);
|
||||
a.href = href;
|
||||
a.setAttribute('role', 'menuitem');
|
||||
return a;
|
||||
}
|
||||
|
||||
function build(access) {
|
||||
var wrap = el('div', 'profile-menu');
|
||||
|
||||
btnEl = el('button', 'btn btn-secondary profile-btn');
|
||||
btnEl.type = 'button';
|
||||
btnEl.id = 'profile-btn';
|
||||
btnEl.title = 'Account: ' + (access.email || 'signed in');
|
||||
btnEl.setAttribute('aria-haspopup', 'menu');
|
||||
btnEl.setAttribute('aria-expanded', 'false');
|
||||
var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase();
|
||||
btnEl.appendChild(el('span', 'profile-btn__avatar', initial));
|
||||
btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); });
|
||||
wrap.appendChild(btnEl);
|
||||
|
||||
panelEl = el('div', 'profile-menu__panel');
|
||||
panelEl.setAttribute('role', 'menu');
|
||||
|
||||
var id = el('div', 'profile-menu__id');
|
||||
id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)'));
|
||||
if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin'));
|
||||
else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin'));
|
||||
panelEl.appendChild(id);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
|
||||
// Admin mode — only offered to principals who actually have admin
|
||||
// authority somewhere (can_elevate). Drops automatically on leave.
|
||||
if (access.can_elevate && elevation) {
|
||||
var row = el('label', 'profile-menu__item profile-menu__toggle');
|
||||
adminInput = document.createElement('input');
|
||||
adminInput.type = 'checkbox';
|
||||
adminInput.className = 'profile-menu__check';
|
||||
adminInput.checked = isElevated();
|
||||
adminInput.addEventListener('change', function () {
|
||||
if (adminInput.checked) elevation.setOn(); else elevation.setOff();
|
||||
});
|
||||
row.appendChild(adminInput);
|
||||
var txt = el('span', 'profile-menu__toggle-label');
|
||||
txt.appendChild(el('span', null, 'Admin mode'));
|
||||
txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page'));
|
||||
row.appendChild(txt);
|
||||
panelEl.appendChild(row);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
}
|
||||
|
||||
panelEl.appendChild(linkItem('Profile', '/.profile'));
|
||||
panelEl.appendChild(linkItem('Access tokens', '/.tokens'));
|
||||
// No "Sign out": authentication is the upstream proxy's concern
|
||||
// (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it
|
||||
// doesn't render a logout affordance.
|
||||
|
||||
// Portal the panel to <body>, not inside the header: the app's
|
||||
// layout creates stacking contexts that trap even a fixed+high
|
||||
// z-index panel below the content. As a direct body child it sits in
|
||||
// the root stacking context and reliably overlays everything.
|
||||
// position:fixed + positionPanel() keep it anchored to the button.
|
||||
document.body.appendChild(panelEl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (window.location.protocol === 'file:') return;
|
||||
elevation = window.zddc.elevation || null;
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.email) return; // unauthenticated / non-zddc backend
|
||||
var host = document.querySelector('.header-right');
|
||||
if (!host) return;
|
||||
|
||||
host.appendChild(build(access));
|
||||
syncArmed();
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (panelEl && panelEl.classList.contains('open')
|
||||
&& !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
|
||||
window.addEventListener('zddc:elevationchange', syncArmed);
|
||||
|
||||
window.zddc.profileMenu = { close: closeMenu };
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
||||
// shared/cap.js — client-side capability helpers for permission gating.
|
||||
|
|
@ -6743,6 +7057,264 @@ body.is-elevated::after {
|
|||
};
|
||||
})(window.tablesApp);
|
||||
|
||||
// api-actions.js — generic "tables over an API collection" layer.
|
||||
//
|
||||
// When the injected #table-context carries an `apiActions` block, this turns
|
||||
// the otherwise read-only table into a managed collection backed by a REST
|
||||
// endpoint, WITHOUT touching the file-save/row-ops machinery (which is bound
|
||||
// to <dir>/*.yaml row files). It drives create + per-row delete against the
|
||||
// configured URLs and reloads on success (the server re-renders the fresh
|
||||
// list). First consumer: the self-service token page at /.tokens.
|
||||
//
|
||||
// apiActions: {
|
||||
// create: { url, title?, fields:[{name,label,placeholder?,type?}], secretField?, secretLabel? },
|
||||
// deleteRow: { urlTemplate (with {id}), label?, confirm? } // {id} ← row's data-url
|
||||
// }
|
||||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
function ctxObj() {
|
||||
return (app && app.context) || {};
|
||||
}
|
||||
function cfg() {
|
||||
return ctxObj().apiActions || null;
|
||||
}
|
||||
// Active when the table is an API collection (apiActions) OR a read-only
|
||||
// server-injected view (readOnly) — either way the file-model toolbar
|
||||
// buttons (+ Add row / Save) don't apply and are hidden.
|
||||
function active() {
|
||||
return !!(cfg() || ctxObj().readOnly);
|
||||
}
|
||||
|
||||
function el(tag, attrs, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (attrs) Object.keys(attrs).forEach(function (k) { e.setAttribute(k, attrs[k]); });
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────────────
|
||||
var createMounted = false;
|
||||
function mountCreate(c) {
|
||||
if (createMounted) return;
|
||||
var bar = document.querySelector('.table-toolbar__left') || document.getElementById('table-toolbar');
|
||||
if (!bar) return;
|
||||
// The native "+ Add row" posts to the form-create file endpoint, which
|
||||
// doesn't apply to an API collection — hide it; this button replaces it.
|
||||
var native = document.getElementById('table-add-row');
|
||||
if (native) native.hidden = true;
|
||||
var btn = el('button', { type: 'button', class: 'btn btn-primary btn-sm', id: 'api-create-btn' }, '+ ' + (c.title || 'New'));
|
||||
btn.addEventListener('click', function () { openCreate(c); });
|
||||
bar.appendChild(btn);
|
||||
createMounted = true;
|
||||
}
|
||||
|
||||
function openCreate(c) {
|
||||
var overlay = el('div', { class: 'api-modal__overlay' });
|
||||
var modal = el('div', { class: 'api-modal' });
|
||||
modal.appendChild(el('h2', { class: 'api-modal__title' }, c.title || 'New'));
|
||||
var form = el('form', { class: 'api-modal__form' });
|
||||
var inputs = {};
|
||||
(c.fields || []).forEach(function (f) {
|
||||
var lab = el('label', { class: 'api-modal__field' });
|
||||
lab.appendChild(el('span', null, (f.label || f.name) + (f.required ? ' *' : '')));
|
||||
var inp = el('input', { type: f.type || 'text' });
|
||||
if (f.placeholder) inp.setAttribute('placeholder', f.placeholder);
|
||||
if (f.required) inp.required = true;
|
||||
inputs[f.name] = inp;
|
||||
lab.appendChild(inp);
|
||||
form.appendChild(lab);
|
||||
});
|
||||
var err = el('div', { class: 'api-modal__err', hidden: 'hidden' });
|
||||
form.appendChild(err);
|
||||
var actions = el('div', { class: 'api-modal__actions' });
|
||||
var cancel = el('button', { type: 'button', class: 'btn btn-secondary btn-sm' }, 'Cancel');
|
||||
var submit = el('button', { type: 'submit', class: 'btn btn-primary btn-sm' }, 'Create');
|
||||
actions.appendChild(cancel); actions.appendChild(submit);
|
||||
form.appendChild(actions);
|
||||
modal.appendChild(form);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
var firstInput = form.querySelector('input');
|
||||
if (firstInput) firstInput.focus();
|
||||
|
||||
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
|
||||
cancel.addEventListener('click', close);
|
||||
overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); });
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
err.hidden = true;
|
||||
var missing = (c.fields || []).filter(function (f) { return f.required && !inputs[f.name].value.trim(); });
|
||||
if (missing.length) {
|
||||
err.textContent = 'Required: ' + missing.map(function (f) { return f.label || f.name; }).join(', ');
|
||||
err.hidden = false;
|
||||
return;
|
||||
}
|
||||
var body = {};
|
||||
(c.fields || []).forEach(function (f) {
|
||||
var v = inputs[f.name].value.trim();
|
||||
if (!v) return;
|
||||
// Date fields → RFC3339 so the Go time.Time decoder accepts them.
|
||||
body[f.name] = (f.type === 'date') ? new Date(v + 'T00:00:00').toISOString() : v;
|
||||
});
|
||||
// Constant fields the server requires but the user doesn't set
|
||||
// (e.g. project create's parent="/").
|
||||
if (c.fixed) Object.keys(c.fixed).forEach(function (k) { body[k] = c.fixed[k]; });
|
||||
submit.disabled = true;
|
||||
fetch(c.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(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) {
|
||||
submit.disabled = false;
|
||||
err.textContent = 'Create failed: ' + res.status + ' ' + res.text;
|
||||
err.hidden = false;
|
||||
return;
|
||||
}
|
||||
close();
|
||||
var secret = '';
|
||||
if (c.secretField) {
|
||||
try { secret = (JSON.parse(res.text) || {})[c.secretField] || ''; } catch (_e) { /* ignore */ }
|
||||
}
|
||||
if (secret) showSecret(c.secretLabel || 'New secret (shown once):', secret);
|
||||
else location.reload();
|
||||
}).catch(function (e2) {
|
||||
submit.disabled = false;
|
||||
err.textContent = 'Create failed: ' + (e2 && e2.message ? e2.message : e2);
|
||||
err.hidden = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showSecret(label, secret) {
|
||||
var overlay = el('div', { class: 'api-modal__overlay' });
|
||||
var modal = el('div', { class: 'api-modal' });
|
||||
modal.appendChild(el('p', { class: 'api-modal__secret-label' }, label));
|
||||
var box = el('div', { class: 'api-modal__secret' }, secret);
|
||||
modal.appendChild(box);
|
||||
var actions = el('div', { class: 'api-modal__actions' });
|
||||
var copy = el('button', { type: 'button', class: 'btn btn-secondary btn-sm' }, 'Copy');
|
||||
copy.addEventListener('click', function () {
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(secret);
|
||||
copy.textContent = 'Copied';
|
||||
});
|
||||
var done = el('button', { type: 'button', class: 'btn btn-primary btn-sm' }, 'Done');
|
||||
done.addEventListener('click', function () { location.reload(); });
|
||||
actions.appendChild(copy); actions.appendChild(done);
|
||||
modal.appendChild(actions);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// ── Per-row delete ──────────────────────────────────────────────────────
|
||||
function ensureRowDelete(d) {
|
||||
var tbody = document.querySelector('#table-root tbody');
|
||||
if (!tbody) return;
|
||||
var trs = tbody.querySelectorAll('tr');
|
||||
for (var i = 0; i < trs.length; i++) {
|
||||
var tr = trs[i];
|
||||
if (tr.querySelector('.api-revoke')) continue;
|
||||
var id = tr.getAttribute('data-url');
|
||||
if (!id) continue;
|
||||
var cell = tr.lastElementChild;
|
||||
if (!cell) continue;
|
||||
var b = el('button', { type: 'button', class: 'btn btn-secondary btn-sm api-revoke' }, d.label || 'Delete');
|
||||
(function (rowId) {
|
||||
b.addEventListener('click', function () { revoke(d, rowId); });
|
||||
})(id);
|
||||
cell.appendChild(b);
|
||||
}
|
||||
}
|
||||
|
||||
function revoke(d, id) {
|
||||
if (d.confirm && !window.confirm(d.confirm)) return;
|
||||
var url = d.urlTemplate.replace('{id}', encodeURIComponent(id));
|
||||
fetch(url, { method: 'DELETE', credentials: 'same-origin' }).then(function (r) {
|
||||
if (r.ok || r.status === 204) location.reload();
|
||||
else r.text().then(function (t) { window.alert('Delete failed: ' + r.status + ' ' + t); });
|
||||
}).catch(function (e) { window.alert('Delete failed: ' + (e && e.message ? e.message : e)); });
|
||||
}
|
||||
|
||||
// Suppress the file-model toolbar affordances that don't apply to an API
|
||||
// collection: native "+ Add row" (posts to the form-create file endpoint)
|
||||
// and "Save" (flushes dirty row files). Re-run each tick in case main.js
|
||||
// toggles them after us.
|
||||
function hideNative() {
|
||||
// Use inline display:none, not the [hidden] attr — the .btn display
|
||||
// rule overrides [hidden] and the buttons would stay visible.
|
||||
['table-add-row', 'table-save'].forEach(function (id) {
|
||||
var b = document.getElementById(id);
|
||||
if (b) b.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Per-row navigation: clicking a row opens its data-url (the project /
|
||||
// subtree it represents) — used by the profile "Effective access" table.
|
||||
// Clicks on inner controls (buttons/links/inputs) are left alone.
|
||||
function ensureRowNav() {
|
||||
var tbody = document.querySelector('#table-root tbody');
|
||||
if (!tbody) return;
|
||||
var trs = tbody.querySelectorAll('tr');
|
||||
for (var i = 0; i < trs.length; i++) {
|
||||
var tr = trs[i];
|
||||
if (tr.getAttribute('data-nav') === '1') continue;
|
||||
var url = tr.getAttribute('data-url');
|
||||
if (!url) continue;
|
||||
tr.setAttribute('data-nav', '1');
|
||||
tr.style.cursor = 'pointer';
|
||||
(function (target) {
|
||||
// Capture phase: fire before the tables editor's per-cell
|
||||
// click handlers (which would otherwise swallow the click on
|
||||
// read-only rows). Inner controls (buttons/links/inputs) still
|
||||
// opt out.
|
||||
tr.addEventListener('click', function (e) {
|
||||
if (e.target.closest('button, a, input')) return;
|
||||
window.location.href = target;
|
||||
}, true);
|
||||
})(url);
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!active()) return;
|
||||
hideNative();
|
||||
var c = cfg();
|
||||
if (!c) return; // read-only view: native buttons hidden, nothing more
|
||||
if (c.create) mountCreate(c.create);
|
||||
if (c.deleteRow) ensureRowDelete(c.deleteRow);
|
||||
if (c.rowNav) ensureRowNav();
|
||||
}
|
||||
|
||||
function start() {
|
||||
// app.context is set asynchronously by main.js (await context.load()).
|
||||
// Poll until it's present, then run once + observe the tbody so the
|
||||
// per-row buttons survive sort/filter re-renders.
|
||||
var tries = 0;
|
||||
var iv = setInterval(function () {
|
||||
if (active() || tries++ > 60) {
|
||||
clearInterval(iv);
|
||||
if (!active()) return;
|
||||
tick();
|
||||
var tbody = document.querySelector('#table-root tbody');
|
||||
if (tbody && window.MutationObserver) {
|
||||
new MutationObserver(function () { tick(); }).observe(tbody, { childList: true });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})(window.tablesApp = window.tablesApp || {});
|
||||
|
||||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
|
|
|
|||
|
|
@ -209,9 +209,85 @@ func ServeTokensPage(cfg config.Config, store *auth.Store, w http.ResponseWriter
|
|||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
storeAvailable := store != nil
|
||||
body := renderTokensPage(email, storeAvailable)
|
||||
_, _ = w.Write([]byte(body))
|
||||
|
||||
// Render the token list through the shared tables engine (chrome +
|
||||
// declarative columns) with a server-injected collection, instead of a
|
||||
// bespoke chrome-less page. Create + revoke are driven by the generic
|
||||
// apiActions layer against the existing /.api/tokens endpoints (the
|
||||
// tables file-save path is untouched). Falls back to the legacy
|
||||
// skeleton if the store or the tables renderer isn't available.
|
||||
tablesHTML := EmbeddedTablesHTML()
|
||||
if store == nil || len(tablesHTML) == 0 {
|
||||
_, _ = w.Write([]byte(renderTokensPage(email, store != nil)))
|
||||
return
|
||||
}
|
||||
injected, err := injectTableContextObj(tablesHTML, buildTokensTableContext(store, email))
|
||||
if err != nil {
|
||||
_, _ = w.Write([]byte(renderTokensPage(email, true)))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(injected)
|
||||
}
|
||||
|
||||
// buildTokensTableContext assembles the pre-rendered #table-context for the
|
||||
// token page: the user's tokens as read-only rows + the apiActions config that
|
||||
// wires create/revoke to /.api/tokens (create surfaces the one-time secret).
|
||||
func buildTokensTableContext(store *auth.Store, email string) map[string]interface{} {
|
||||
rows := []map[string]interface{}{}
|
||||
if list, err := store.List(email); err == nil {
|
||||
for _, t := range list {
|
||||
exp := "never"
|
||||
if !t.Expires.IsZero() {
|
||||
exp = t.Expires.Format("2006-01-02")
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"url": t.ID(),
|
||||
"editable": false,
|
||||
"data": map[string]interface{}{
|
||||
"description": t.Description,
|
||||
"created": t.Created.Format("2006-01-02 15:04"),
|
||||
"expires": exp,
|
||||
"id": t.ID(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
col := func(field, title, width string) map[string]interface{} {
|
||||
c := map[string]interface{}{"field": field, "title": title}
|
||||
if width != "" {
|
||||
c["width"] = width
|
||||
}
|
||||
return c
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"title": "API tokens",
|
||||
"description": "Bearer tokens for CLI / scripted access as " + email + ". A token's secret is shown once, at creation.",
|
||||
"addable": false,
|
||||
"columns": []map[string]interface{}{
|
||||
col("description", "Description", ""),
|
||||
col("created", "Created", "12em"),
|
||||
col("expires", "Expires", "9em"),
|
||||
col("id", "ID", "16em"),
|
||||
},
|
||||
"rows": rows,
|
||||
"apiActions": map[string]interface{}{
|
||||
"create": map[string]interface{}{
|
||||
"url": TokensAPIPathPrefix,
|
||||
"title": "New token",
|
||||
"secretField": "token",
|
||||
"secretLabel": "New token — copy it now, it is shown only once:",
|
||||
"fields": []map[string]interface{}{
|
||||
{"name": "description", "label": "Description", "placeholder": "e.g. Field laptop"},
|
||||
{"name": "expires", "label": "Expires (optional)", "type": "date"},
|
||||
},
|
||||
},
|
||||
"deleteRow": map[string]interface{}{
|
||||
"urlTemplate": TokensAPIPathPrefix + "/{id}",
|
||||
"label": "Revoke",
|
||||
"confirm": "Revoke this token? Any client using it will stop working.",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// renderTokensPage builds the HTML for the management page. Kept inline
|
||||
|
|
|
|||
|
|
@ -456,3 +456,44 @@ func TestWithEmail(t *testing.T) {
|
|||
t.Errorf("EmailFromContext = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTokensTableContext locks the server-injected token table contract:
|
||||
// only the caller's own tokens become rows, each row carries its id (for the
|
||||
// delete action), and apiActions wires create (with the one-time secret) +
|
||||
// per-row delete to /.api/tokens.
|
||||
func TestBuildTokensTableContext(t *testing.T) {
|
||||
store := newTestTokenStore(t)
|
||||
if _, _, err := store.Generate("alice@example.com", "Field laptop", time.Time{}); err != nil {
|
||||
t.Fatalf("Generate alice: %v", err)
|
||||
}
|
||||
if _, _, err := store.Generate("mallory@example.com", "other", time.Time{}); err != nil {
|
||||
t.Fatalf("Generate mallory: %v", err)
|
||||
}
|
||||
|
||||
ctx := buildTokensTableContext(store, "alice@example.com")
|
||||
if ctx["title"] != "API tokens" {
|
||||
t.Errorf("title = %v", ctx["title"])
|
||||
}
|
||||
|
||||
rows, ok := ctx["rows"].([]map[string]interface{})
|
||||
if !ok || len(rows) != 1 {
|
||||
t.Fatalf("rows = %#v, want exactly alice's one token", ctx["rows"])
|
||||
}
|
||||
data, _ := rows[0]["data"].(map[string]interface{})
|
||||
if data["description"] != "Field laptop" {
|
||||
t.Errorf("row description = %v", data["description"])
|
||||
}
|
||||
if id, _ := rows[0]["url"].(string); id == "" {
|
||||
t.Errorf("row missing url (token id needed for the delete action)")
|
||||
}
|
||||
|
||||
api, _ := ctx["apiActions"].(map[string]interface{})
|
||||
create, _ := api["create"].(map[string]interface{})
|
||||
if create["url"] != TokensAPIPathPrefix || create["secretField"] != "token" {
|
||||
t.Errorf("apiActions.create = %#v, want url=%s secretField=token", create, TokensAPIPathPrefix)
|
||||
}
|
||||
del, _ := api["deleteRow"].(map[string]interface{})
|
||||
if del["urlTemplate"] != TokensAPIPathPrefix+"/{id}" {
|
||||
t.Errorf("apiActions.deleteRow.urlTemplate = %v", del["urlTemplate"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ func IsZddcFileRequest(urlPath string) bool {
|
|||
//
|
||||
// Virtual: if it does not exist, the body is the cascade's
|
||||
//
|
||||
// leaf-level ZddcFile (what defaults.zddc.yaml's paths:
|
||||
// leaf-level ZddcFile (what internal/zddc/defaults/'s paths:
|
||||
// tree declares for THIS exact directory, plus any
|
||||
// virtual contributions threaded through by the walker)
|
||||
// marshalled as YAML. A header comment names the source
|
||||
|
|
@ -143,9 +143,10 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// renderVirtualZddc produces a YAML body for a directory that has no
|
||||
// .zddc on disk. The body is the cascade's leaf-level ZddcFile —
|
||||
// i.e. what defaults.zddc.yaml's paths: tree declares for this exact
|
||||
// directory, plus any contributions the walker threaded through. The
|
||||
// goal is to expose the embedded defaults' source of truth: a new
|
||||
// i.e. what the built-in defaults bundle (exportable/overridable as a
|
||||
// root .zddc.zip via `zddc-server show-defaults`) declares for this
|
||||
// exact directory, plus any contributions the walker threaded through.
|
||||
// The goal is to expose the baseline's source of truth: a new
|
||||
// user opening the virtual .zddc here sees, in the same yaml shape
|
||||
// they would write themselves, what behavior is currently declared
|
||||
// at this level. A header comment names the source and points at
|
||||
|
|
@ -163,13 +164,16 @@ func renderVirtualZddc(chain zddc.PolicyChain) (string, error) {
|
|||
|
||||
var b strings.Builder
|
||||
b.WriteString("# Virtual .zddc — no file on disk at this directory.\n")
|
||||
b.WriteString("# The content below is what the embedded defaults\n")
|
||||
b.WriteString("# (defaults.zddc.yaml's paths: tree) declare for this\n")
|
||||
b.WriteString("# exact path. Edit and save through the YAML editor in\n")
|
||||
b.WriteString("# browse to materialise a real .zddc here carrying your\n")
|
||||
b.WriteString("# changes; the bytes you save become the override\n")
|
||||
b.WriteString("# verbatim (no merge, no synthesis — .zddc files drive\n")
|
||||
b.WriteString("# policy and are the single source of truth).\n")
|
||||
b.WriteString("# The content below is what the policy baseline declares\n")
|
||||
b.WriteString("# for this exact path: the built-in defaults bundle — the\n")
|
||||
b.WriteString("# same one you can export, and override, as a root\n")
|
||||
b.WriteString("# .zddc.zip (`zddc-server show-defaults`) — with any\n")
|
||||
b.WriteString("# on-disk ancestor .zddc overrides already threaded in.\n")
|
||||
b.WriteString("# Edit and save through the YAML editor in browse to\n")
|
||||
b.WriteString("# materialise a real .zddc here carrying your changes;\n")
|
||||
b.WriteString("# the bytes you save become the override verbatim (no\n")
|
||||
b.WriteString("# merge, no synthesis — .zddc files drive policy and are\n")
|
||||
b.WriteString("# the single source of truth).\n")
|
||||
b.WriteString("#\n")
|
||||
b.WriteString("# For the COMPOSED effective config across the whole\n")
|
||||
b.WriteString("# cascade (all ancestors merged), query:\n")
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ func TestServeZddcFile_ExistingFile(t *testing.T) {
|
|||
// (project_team: r, observer: r, document_controller: rw) plus the
|
||||
// canonical paths: tree (archive, working, staging, reviewing, …).
|
||||
// Asserts a few load-bearing markers; the full content is the
|
||||
// `defaults.zddc.yaml` source-of-truth, which lives under
|
||||
// `internal/zddc/defaults/` source-of-truth, which lives under
|
||||
// zddc/internal/zddc and is parsed at every cascade walk.
|
||||
func TestServeZddcFile_VirtualDefault(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
|
|
|||
255
zddc/internal/handler/zipwrite.go
Normal file
255
zddc/internal/handler/zipwrite.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// Edit-in-place for the .zddc.zip config bundle. A zip is a random-access
|
||||
// container (unlike a streamed .tgz), so a member can be rewritten without
|
||||
// re-encoding the operator's intent — we read the whole archive, mutate one
|
||||
// member, snapshot the old version into an in-zip .history/, and atomically
|
||||
// replace the file. Gated to active admins (dispatch already 404s the bundle
|
||||
// to everyone else) and to the .zddc.zip bundle specifically; content zips stay
|
||||
// read-only.
|
||||
//
|
||||
// History layout INSIDE the bundle (so edits travel with it):
|
||||
//
|
||||
// .history/<member>/<RFC3339-nano timestamp> the prior bytes
|
||||
// .history/<member>/log.jsonl append-only audit (ts, email, op)
|
||||
//
|
||||
// Writes serialize on one mutex — admin bundle edits are infrequent, and a
|
||||
// whole-archive rewrite must not interleave.
|
||||
var zipWriteMu sync.Mutex
|
||||
|
||||
const zipHistoryDir = ".history"
|
||||
|
||||
// ServeZipWrite handles PUT (write/create a member) and DELETE (remove a
|
||||
// member) inside a .zddc.zip bundle. member is the path within the zip.
|
||||
func ServeZipWrite(cfg config.Config, w http.ResponseWriter, r *http.Request, zipAbs, member string) {
|
||||
member = strings.TrimLeft(member, "/")
|
||||
if member == "" || strings.HasSuffix(member, "/") {
|
||||
http.Error(w, "Bad Request — a zip member path is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if member == zipHistoryDir || strings.HasPrefix(member, zipHistoryDir+"/") {
|
||||
http.Error(w, "Forbidden — the in-zip .history/ store is append-only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
body, ok := readBodyCapped(cfg, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
zipMutate(cfg, w, r, zipAbs, member, body, false)
|
||||
case http.MethodDelete:
|
||||
zipMutate(cfg, w, r, zipAbs, member, nil, true)
|
||||
default:
|
||||
w.Header().Set("Allow", "PUT, DELETE")
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// zipLogLine is one append-only audit record in an in-zip log.jsonl.
|
||||
type zipLogLine struct {
|
||||
TS string `json:"ts"`
|
||||
Email string `json:"email"`
|
||||
Op string `json:"op"`
|
||||
Bytes int `json:"bytes"`
|
||||
}
|
||||
|
||||
func zipMutate(cfg config.Config, w http.ResponseWriter, r *http.Request, zipAbs, member string, body []byte, del bool) {
|
||||
zipWriteMu.Lock()
|
||||
defer zipWriteMu.Unlock()
|
||||
|
||||
members, order, err := readZipMembers(zipAbs)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error — read bundle: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
old, existed := members[member]
|
||||
if del && !existed {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Snapshot the prior bytes + append an audit line BEFORE mutating.
|
||||
if existed {
|
||||
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000000000Z")
|
||||
histPrefix := zipHistoryDir + "/" + member + "/"
|
||||
addMember(members, &order, histPrefix+ts, old)
|
||||
op := "put"
|
||||
if del {
|
||||
op = "delete"
|
||||
}
|
||||
line, _ := json.Marshal(zipLogLine{TS: ts, Email: EmailFromContext(r), Op: op, Bytes: len(body)})
|
||||
logKey := histPrefix + "log.jsonl"
|
||||
members[logKey] = append(append(append([]byte{}, members[logKey]...), line...), '\n')
|
||||
// log.jsonl may be newly created here.
|
||||
ensureInOrder(&order, logKey)
|
||||
}
|
||||
|
||||
if del {
|
||||
delete(members, member)
|
||||
order = removeFromOrder(order, member)
|
||||
} else {
|
||||
addMember(members, &order, member, body)
|
||||
}
|
||||
|
||||
if err := writeZipAtomic(zipAbs, members, order); err != nil {
|
||||
auditFile(r, zipOp(del), r.URL.Path, http.StatusInternalServerError, 0, err)
|
||||
http.Error(w, "Internal Server Error — write bundle: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// A .zddc.zip change can alter both policy (its .zddc members feed the
|
||||
// cascade) and tool HTML (apps.Bundle, which hot-reloads on mtime). Clear
|
||||
// the policy cache so the next decision re-reads the bundle.
|
||||
zddc.InvalidateCache(cfg.Root)
|
||||
|
||||
if del {
|
||||
w.Header().Set("X-ZDDC-Source", "zip:delete")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
auditFile(r, "zip-delete", r.URL.Path, http.StatusNoContent, 0, nil)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if !existed {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
w.Header().Set("ETag", `"`+fileETag(body)+`"`)
|
||||
w.Header().Set("X-ZDDC-Source", "zip:put")
|
||||
w.WriteHeader(status)
|
||||
auditFile(r, "zip-put", r.URL.Path, status, len(body), nil)
|
||||
}
|
||||
|
||||
func zipOp(del bool) string {
|
||||
if del {
|
||||
return "zip-delete"
|
||||
}
|
||||
return "zip-put"
|
||||
}
|
||||
|
||||
// readZipMembers loads every member of the zip at zipAbs into a name→bytes map
|
||||
// plus an order slice (insertion order, for stable rewrites).
|
||||
func readZipMembers(zipAbs string) (map[string][]byte, []string, error) {
|
||||
zr, err := zip.OpenReader(zipAbs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer zr.Close()
|
||||
members := make(map[string][]byte, len(zr.File))
|
||||
order := make([]string, 0, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
data, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, dup := members[f.Name]; !dup {
|
||||
order = append(order, f.Name)
|
||||
}
|
||||
members[f.Name] = data
|
||||
}
|
||||
return members, order, nil
|
||||
}
|
||||
|
||||
func addMember(members map[string][]byte, order *[]string, name string, data []byte) {
|
||||
if _, ok := members[name]; !ok {
|
||||
*order = append(*order, name)
|
||||
}
|
||||
members[name] = data
|
||||
}
|
||||
|
||||
func ensureInOrder(order *[]string, name string) {
|
||||
for _, n := range *order {
|
||||
if n == name {
|
||||
return
|
||||
}
|
||||
}
|
||||
*order = append(*order, name)
|
||||
}
|
||||
|
||||
func removeFromOrder(order []string, name string) []string {
|
||||
out := order[:0]
|
||||
for _, n := range order {
|
||||
if n != name {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeZipAtomic writes members to a fresh zip in the same directory and renames
|
||||
// it over zipAbs. Members are emitted in `order` (sorted as a tiebreak for any
|
||||
// not in order) so rewrites are deterministic.
|
||||
func writeZipAtomic(zipAbs string, members map[string][]byte, order []string) error {
|
||||
// Any member not captured in order (defensive) goes last, sorted.
|
||||
seen := make(map[string]bool, len(order))
|
||||
for _, n := range order {
|
||||
seen[n] = true
|
||||
}
|
||||
var extra []string
|
||||
for n := range members {
|
||||
if !seen[n] {
|
||||
extra = append(extra, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(extra)
|
||||
names := append(append([]string{}, order...), extra...)
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(zipAbs), ".zddc.zip.tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // no-op after a successful rename
|
||||
|
||||
zw := zip.NewWriter(tmp)
|
||||
for _, name := range names {
|
||||
data, ok := members[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fw, err := zw.Create(name)
|
||||
if err != nil {
|
||||
zw.Close()
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := fw.Write(data); err != nil {
|
||||
zw.Close()
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, zipAbs)
|
||||
}
|
||||
31
zddc/internal/handler/zipwrite_rt_test.go
Normal file
31
zddc/internal/handler/zipwrite_rt_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestZipWriteRoundTrip(t *testing.T) {
|
||||
zp := filepath.Join(t.TempDir(), ".zddc.zip")
|
||||
if err := writeZipAtomic(zp, map[string][]byte{"a.txt": []byte("v1")}, []string{"a.txt"}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
m, ord, err := readZipMembers(zp)
|
||||
if err != nil {
|
||||
t.Fatalf("read1: %v", err)
|
||||
}
|
||||
addMember(m, &ord, "*/.zddc", []byte("hello-wildcard"))
|
||||
if err := writeZipAtomic(zp, m, ord); err != nil {
|
||||
t.Fatalf("write2: %v", err)
|
||||
}
|
||||
m2, _, err := readZipMembers(zp)
|
||||
if err != nil {
|
||||
t.Fatalf("read2: %v", err)
|
||||
}
|
||||
if got := string(m2["*/.zddc"]); got != "hello-wildcard" {
|
||||
t.Errorf("wildcard member = %q, want hello-wildcard", got)
|
||||
}
|
||||
if got := string(m2["a.txt"]); got != "v1" {
|
||||
t.Errorf("a.txt = %q, want v1", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -94,4 +94,12 @@ type FileInfo struct {
|
|||
// where they apply. False/absent for directories, virtual entries,
|
||||
// and files outside a history-enabled subtree.
|
||||
History bool `json:"history,omitempty"`
|
||||
|
||||
// DefaultTool is the cascade-resolved default tool for a DIRECTORY
|
||||
// entry (the tool served at <dir> with no trailing slash) — e.g.
|
||||
// "tables", "classifier", "browse". Empty for files and for dirs with
|
||||
// no declared default. Browse uses it to render a tool-typed dir as a
|
||||
// leaf that opens the tool in the preview pane instead of expanding —
|
||||
// e.g. mdl/rsk/ssr (default_tool=tables) become click-to-table entries.
|
||||
DefaultTool string `json:"default_tool,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -235,8 +235,22 @@ func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, erro
|
|||
return true, nil
|
||||
}
|
||||
|
||||
// Standing config-edit. Authority to mutate configuration (.zddc /
|
||||
// .zddc.zip / role definitions — the only actions that map to VerbA)
|
||||
// is a STANDING permission: a subtree admin (admins: cascade) or a
|
||||
// holder of the `a` verb may edit the config of subtrees they
|
||||
// administer WITHOUT elevating. This sits ABOVE the WORM clamp because
|
||||
// config is not WORM-protected data — and it only ever grants VerbA,
|
||||
// so it can never write/delete/create WORM *records* (those need
|
||||
// W/C/D, which stay clamped and behind the elevated bypass above).
|
||||
// Elevation is thus purely additive: it adds the WORM/destructive
|
||||
// overrides, never gating config-edit you already have authority for.
|
||||
if verb == zddc.VerbA && zddc.IsConfigEditor(chain, email) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// WORM zone: a directory whose cascade declares `worm:` (see
|
||||
// defaults.zddc.yaml — archive/<party>/received and issued carry
|
||||
// internal/zddc/defaults/ — archive/<party>/received and issued carry
|
||||
// `worm: {}`) is write-locked. Inside it, the effective verbs
|
||||
// for a non-admin principal are:
|
||||
//
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ func TestAllowActionFromChainP_TruthTable(t *testing.T) {
|
|||
read, write, create, deleteV, adminV bool
|
||||
}
|
||||
allActions := want{true, true, true, true, true}
|
||||
noAdmin := want{true, true, true, true, false} // staff has rwcd but no `a`
|
||||
noAdmin := want{true, true, true, true, false} // staff has rwcd but no `a`
|
||||
configOnly := want{adminV: true} // standing config-edit, nothing else
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
|
|
@ -76,20 +77,21 @@ func TestAllowActionFromChainP_TruthTable(t *testing.T) {
|
|||
},
|
||||
|
||||
// ─── ELEVATION GATE ─────────────────────────────────────────
|
||||
// An admin who hasn't elevated MUST be treated as a normal
|
||||
// user. They don't carry any baseline ACL grant in this
|
||||
// fixture, so every action is denied.
|
||||
// An admin who hasn't elevated gets the WORM/destructive bypass
|
||||
// on NOTHING — but config-edit (the `a` verb) is a STANDING
|
||||
// permission, so ActionAdmin is allowed while r/w/c/d (no ACL
|
||||
// grant in this fixture) stay denied. Elevation is additive.
|
||||
{
|
||||
name: "root admin NOT elevated → no bypass, no ACL grant → all denied",
|
||||
name: "root admin NOT elevated → standing config-edit only",
|
||||
email: "root@example.com",
|
||||
elevated: false,
|
||||
want: want{},
|
||||
want: configOnly,
|
||||
},
|
||||
{
|
||||
name: "subtree admin NOT elevated → no bypass, no ACL grant → all denied",
|
||||
name: "subtree admin NOT elevated → standing config-edit only",
|
||||
email: "sub@example.com",
|
||||
elevated: false,
|
||||
want: want{},
|
||||
want: configOnly,
|
||||
},
|
||||
|
||||
// ─── NON-ADMIN PATHS ────────────────────────────────────────
|
||||
|
|
@ -267,9 +269,11 @@ func TestAllowActionFromChainP_BypassWinsOverWorm(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// Negative control: same principal un-elevated must NOT bypass WORM.
|
||||
// Negative control: same principal un-elevated must NOT bypass WORM for
|
||||
// DATA ops. Write/Delete (and Create) of records stay clamped — those
|
||||
// are the destructive overrides elevation exists for.
|
||||
pUn := zddc.Principal{Email: "root@example.com", Elevated: false}
|
||||
for _, action := range []string{ActionWrite, ActionDelete, ActionAdmin} {
|
||||
for _, action := range []string{ActionWrite, ActionDelete} {
|
||||
t.Run("un-elevated admin in WORM zone — "+action, func(t *testing.T) {
|
||||
got, _ := AllowActionFromChainP(ctx, d, chain, pUn, "/received/x", action)
|
||||
if got {
|
||||
|
|
@ -277,4 +281,12 @@ func TestAllowActionFromChainP_BypassWinsOverWorm(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
// EXCEPTION: ActionAdmin (config-edit) is a STANDING permission and
|
||||
// transcends the WORM clamp — a subtree admin may fix the .zddc that
|
||||
// governs a WORM zone without elevating. This grants only VerbA, never
|
||||
// write/delete of the WORM records themselves (asserted just above).
|
||||
if got, _ := AllowActionFromChainP(ctx, d, chain, pUn, "/received/.zddc", ActionAdmin); !got {
|
||||
t.Errorf("un-elevated admin ActionAdmin denied in WORM zone; config-edit should be standing")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
74
zddc/internal/policy/standing_config_test.go
Normal file
74
zddc/internal/policy/standing_config_test.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// TestStandingConfigEdit pins the elevation-independent config-edit model:
|
||||
// a subtree admin (admins: cascade) or an `a`-verb holder may edit config
|
||||
// (ActionAdmin → VerbA) WITHOUT elevating — including above a WORM clamp —
|
||||
// while WORM *data* writes and the other escape hatches stay behind the
|
||||
// elevated bypass. See policy.InternalDecider.Allow + zddc.IsConfigEditor.
|
||||
func TestStandingConfigEdit(t *testing.T) {
|
||||
d := &InternalDecider{}
|
||||
dec := func(chain zddc.PolicyChain, p zddc.Principal, action string) bool {
|
||||
ok, _ := AllowActionFromChainP(context.Background(), d, chain, p, "/proj/probe", action)
|
||||
return ok
|
||||
}
|
||||
alice := func(elev bool) zddc.Principal { return zddc.Principal{Email: "alice@x", Elevated: elev} }
|
||||
|
||||
// admins: [alice] — subtree admin via the cascade.
|
||||
adminChain := zddc.PolicyChain{
|
||||
Levels: []zddc.ZddcFile{{Admins: []string{"alice@x"}}},
|
||||
HasAnyFile: true,
|
||||
}
|
||||
// acl: alice holds ONLY the `a` verb (config-edit, no rwcd).
|
||||
aVerbChain := zddc.PolicyChain{
|
||||
Levels: []zddc.ZddcFile{{ACL: zddc.ACLRules{Permissions: map[string]string{"alice@x": "a"}}}},
|
||||
HasAnyFile: true,
|
||||
}
|
||||
// acl: alice holds rw but NOT a.
|
||||
rwChain := zddc.PolicyChain{
|
||||
Levels: []zddc.ZddcFile{{ACL: zddc.ACLRules{Permissions: map[string]string{"alice@x": "rw"}}}},
|
||||
HasAnyFile: true,
|
||||
}
|
||||
// admins: [alice] AND a WORM zone (a non-nil worm list marks the zone).
|
||||
wormAdminChain := zddc.PolicyChain{
|
||||
Levels: []zddc.ZddcFile{{Admins: []string{"alice@x"}, Worm: []string{}}},
|
||||
HasAnyFile: true,
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
chain zddc.PolicyChain
|
||||
p zddc.Principal
|
||||
action string
|
||||
want bool
|
||||
}{
|
||||
// The headline: a subtree admin edits config without the toggle.
|
||||
{"subtree admin edits .zddc unelevated", adminChain, alice(false), ActionAdmin, true},
|
||||
// ...but standing config authority does NOT bleed into data writes.
|
||||
{"subtree admin data-write still needs elevation", adminChain, alice(false), ActionWrite, false},
|
||||
{"subtree admin data-write WHEN elevated (bypass)", adminChain, alice(true), ActionWrite, true},
|
||||
// The `a` verb is standing config-edit on its own, independent of admins:.
|
||||
{"a-verb holder edits .zddc unelevated", aVerbChain, alice(false), ActionAdmin, true},
|
||||
{"a-verb holder cannot write data", aVerbChain, alice(false), ActionWrite, false},
|
||||
// Plain write/read must NOT be able to rewrite policy (no self-escalation).
|
||||
{"rw-but-not-a cannot edit .zddc", rwChain, alice(false), ActionAdmin, false},
|
||||
{"rw user can still read", rwChain, alice(false), ActionRead, true},
|
||||
// A stranger gets nothing.
|
||||
{"stranger cannot edit .zddc", adminChain, zddc.Principal{Email: "mallory@x"}, ActionAdmin, false},
|
||||
// Config-edit transcends the WORM clamp (you can fix the policy that
|
||||
// governs a WORM zone), but WORM data is still protected.
|
||||
{"config-edit transcends WORM clamp unelevated", wormAdminChain, alice(false), ActionAdmin, true},
|
||||
{"WORM data write denied to admin unelevated", wormAdminChain, alice(false), ActionWrite, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := dec(tc.chain, tc.p, tc.action); got != tc.want {
|
||||
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,23 @@ func IsAdminForChain(chain PolicyChain, email string) bool {
|
|||
return AdminLevelInChain(chain, email) >= 0
|
||||
}
|
||||
|
||||
// IsConfigEditor reports STANDING authority to edit configuration at this
|
||||
// chain — writing a .zddc / .zddc.zip / role definition (the mutations that
|
||||
// map to VerbA). Authority comes from EITHER being a subtree admin (an
|
||||
// admins: grant anywhere on the chain) OR holding the `a` verb in
|
||||
// acl.permissions. Unlike IsSubtreeAdmin this is NOT elevation-gated:
|
||||
// editing config you administer is a standing permission, not a sudo-style
|
||||
// escape hatch. Elevation (IsActiveAdmin) only ADDS the WORM/destructive
|
||||
// overrides on top — see policy.InternalDecider.Allow, which consults this
|
||||
// for VerbA above the WORM clamp (config is not WORM-protected data, and
|
||||
// VerbA never grants write/delete of records).
|
||||
func IsConfigEditor(chain PolicyChain, email string) bool {
|
||||
if email == "" {
|
||||
return false
|
||||
}
|
||||
return IsAdminForChain(chain, email) || AllowedAction(chain, email, VerbA)
|
||||
}
|
||||
|
||||
// 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:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package zddc
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -126,23 +127,27 @@ func EffectivePolicy(fsRoot, dirPath string) (PolicyChain, error) {
|
|||
return cached.(PolicyChain), nil
|
||||
}
|
||||
|
||||
// Build policy chain: read each on-disk .zddc file.
|
||||
// Build policy chain: read each level's on-disk policy. A level's
|
||||
// contribution is an optional .zddc.zip policy bundle mounted here (a whole
|
||||
// subtree: its own-level member at this level, its deeper members threaded
|
||||
// to descendants via Paths) with the plain <dir>/.zddc overlaid on top
|
||||
// (most-specific human edit wins). Either, both, or neither may be present.
|
||||
onDisk := make([]ZddcFile, 0, len(dirs))
|
||||
hasAny := false
|
||||
for _, dir := range dirs {
|
||||
zddcPath := filepath.Join(dir, ".zddc")
|
||||
_, err := os.Stat(zddcPath)
|
||||
if err == nil {
|
||||
level := ZddcFile{}
|
||||
if zipZf, ok := zipPolicyAt(dir); ok {
|
||||
hasAny = true
|
||||
parsed, perr := ParseFile(zddcPath)
|
||||
if perr != nil {
|
||||
onDisk = append(onDisk, ZddcFile{})
|
||||
} else {
|
||||
onDisk = append(onDisk, parsed)
|
||||
}
|
||||
} else {
|
||||
onDisk = append(onDisk, ZddcFile{})
|
||||
level = zipZf
|
||||
}
|
||||
zddcPath := filepath.Join(dir, ".zddc")
|
||||
if _, err := os.Stat(zddcPath); err == nil {
|
||||
hasAny = true
|
||||
if parsed, perr := ParseFile(zddcPath); perr == nil {
|
||||
level = mergeOverlay(level, parsed)
|
||||
}
|
||||
}
|
||||
onDisk = append(onDisk, level)
|
||||
}
|
||||
|
||||
// Walk ancestor paths: trees alongside the on-disk chain. Each
|
||||
|
|
@ -254,6 +259,31 @@ func EffectivePolicy(fsRoot, dirPath string) (PolicyChain, error) {
|
|||
return chain, nil
|
||||
}
|
||||
|
||||
// zipPolicyAt loads an operator policy bundle at <dir>/.zddc.zip and assembles
|
||||
// it into a single nested ZddcFile (its own-level content + Paths threading its
|
||||
// deeper members to descendants), or (_, false) when the bundle is absent,
|
||||
// unreadable, or carries no .zddc members (e.g. a tool-HTML-only bundle — those
|
||||
// are ignored for policy). Mounting the bundle at dir contributes a policy
|
||||
// subtree there; inherit:false in its resolved .zddc makes that subtree a
|
||||
// self-contained island. Member paths use "*" for the any-segment wildcard,
|
||||
// resolved by the same literal-first matching as paths:.
|
||||
func zipPolicyAt(dir string) (ZddcFile, bool) {
|
||||
zipPath := filepath.Join(dir, ".zddc.zip")
|
||||
if fi, err := os.Stat(zipPath); err != nil || fi.IsDir() {
|
||||
return ZddcFile{}, false
|
||||
}
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return ZddcFile{}, false
|
||||
}
|
||||
defer zr.Close()
|
||||
tree, err := LoadPolicyTreeFromFS(zr, ".")
|
||||
if err != nil || len(tree) == 0 {
|
||||
return ZddcFile{}, false
|
||||
}
|
||||
return tree.Assemble(), true
|
||||
}
|
||||
|
||||
// EffectiveFieldCodes returns the merged field-code vocabulary
|
||||
// visible at the leaf of this chain. Walks root → leaf, applying
|
||||
// map-merge per top-level key (a leaf entry for the same code
|
||||
|
|
|
|||
85
zddc/internal/zddc/cascade_zip_test.go
Normal file
85
zddc/internal/zddc/cascade_zip_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package zddc
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTestZip(t *testing.T, zipPath string, members map[string]string) {
|
||||
t.Helper()
|
||||
f, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
zw := zip.NewWriter(f)
|
||||
for name, body := range members {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// A .zddc.zip dropped at any directory mounts a policy subtree there: its
|
||||
// own-level member governs that directory, its "*"/named members govern
|
||||
// descendants, and inherit:false makes it a self-contained island that ignores
|
||||
// the ancestor cascade + embedded site defaults.
|
||||
func TestZddcZipMountedAtSubtree(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Site root: project_team has a member; embedded defaults apply below.
|
||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
||||
[]byte("roles:\n project_team:\n members: [team@x]\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range []string{"Proj/special", "Proj/normal"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(d)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop a self-contained island at /Proj/special (inherit:false) granting
|
||||
// only *@vendor.com, with a "*" descendant rule (read-only below).
|
||||
// A complete island fences both layers: top-level inherit:false drops the
|
||||
// embedded defaults + ancestor paths: contributions, and acl.inherit:false
|
||||
// clamps the ACL level-walk so ancestor levels' grants don't leak in.
|
||||
writeTestZip(t, filepath.Join(root, "Proj", "special", ".zddc.zip"), map[string]string{
|
||||
".zddc": "inherit: false\nacl:\n inherit: false\n permissions:\n \"*@vendor.com\": rwcd\n",
|
||||
"*/.zddc": "acl:\n permissions:\n \"*@vendor.com\": r\n",
|
||||
})
|
||||
InvalidateCache(root)
|
||||
|
||||
verbs := func(dir, email string) VerbSet {
|
||||
chain, err := EffectivePolicy(root, filepath.Join(root, filepath.FromSlash(dir)))
|
||||
if err != nil {
|
||||
t.Fatalf("EffectivePolicy %s: %v", dir, err)
|
||||
}
|
||||
return EffectiveVerbs(chain, email)
|
||||
}
|
||||
|
||||
// Bundle root member governs /Proj/special.
|
||||
if v := verbs("Proj/special", "u@vendor.com"); !v.Has(VerbC) || !v.Has(VerbW) || !v.Has(VerbD) {
|
||||
t.Errorf("/Proj/special vendor verbs = %v, want rwcd", v)
|
||||
}
|
||||
// Bundle's */.zddc governs a (virtual) descendant — read-only, deepest-wins.
|
||||
if v := verbs("Proj/special/anychild", "u@vendor.com"); !v.Has(VerbR) || v.Has(VerbW) {
|
||||
t.Errorf("/Proj/special/anychild vendor verbs = %v, want r only", v)
|
||||
}
|
||||
// inherit:false fences the site defaults: the embedded project-level
|
||||
// project_team grant has NO effect inside the island.
|
||||
if v := verbs("Proj/special", "team@x"); v != 0 {
|
||||
t.Errorf("/Proj/special team verbs = %v, want none (fenced island)", v)
|
||||
}
|
||||
// Outside the island, the embedded project-level grant still applies.
|
||||
if v := verbs("Proj/normal", "team@x"); !v.Has(VerbR) {
|
||||
t.Errorf("/Proj/normal team verbs = %v, want r (embedded project_team:r)", v)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +1,73 @@
|
|||
package zddc
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// defaultsBytes is the embedded baseline .zddc — see defaults.zddc.yaml
|
||||
// for the source-of-truth and a description of its role in the cascade.
|
||||
// defaultsTreeFS is the embedded per-depth default policy tree — the source of
|
||||
// truth for the shipped baseline, the bottom of every cascade. `all:` includes
|
||||
// the `.zddc` (dot) files and `_any_` (underscore) directories a bare
|
||||
// //go:embed would skip. The `_any_` directory is the on-disk stand-in for the
|
||||
// "*" wildcard segment (kept out of literal "*" directories in the repo).
|
||||
//
|
||||
//go:embed defaults.zddc.yaml
|
||||
var defaultsBytes []byte
|
||||
//go:embed all:defaults
|
||||
var defaultsTreeFS embed.FS
|
||||
|
||||
// EmbeddedDefaultsBytes returns the raw embedded defaults YAML.
|
||||
//
|
||||
// Surface: the show-defaults CLI subcommand dumps these bytes to
|
||||
// stdout so operators can copy them into <ZDDC_ROOT>/.zddc and edit.
|
||||
func EmbeddedDefaultsBytes() []byte {
|
||||
out := make([]byte, len(defaultsBytes))
|
||||
copy(out, defaultsBytes)
|
||||
return out
|
||||
// EmbeddedDefaultsZip packages the embedded per-depth default policy tree as a
|
||||
// .zddc.zip with member paths using the "*" wildcard. The show-defaults CLI
|
||||
// emits this so an operator can drop it at <ZDDC_ROOT>/.zddc.zip — or any
|
||||
// directory — and edit, add, or delete individual members. Mounting it
|
||||
// (optionally with inherit:false + acl.inherit:false to fully replace the
|
||||
// baseline) is how a deployment customizes the shipped policy.
|
||||
func EmbeddedDefaultsZip() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
err := fs.WalkDir(defaultsTreeFS, "defaults", func(p string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
member := strings.ReplaceAll(strings.TrimPrefix(p, "defaults/"), AnyPlaceholder+"/", "*/")
|
||||
data, err := fs.ReadFile(defaultsTreeFS, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := zw.Create(member)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
var (
|
||||
embeddedTreeOnce sync.Once
|
||||
embeddedTree PolicyTree
|
||||
embeddedTreeErr error
|
||||
)
|
||||
|
||||
// EmbeddedPolicyTree returns the baked-in per-depth default policy tree,
|
||||
// memoised. The embedded form of the .zddc.zip mounted at the deployment root.
|
||||
func EmbeddedPolicyTree() (PolicyTree, error) {
|
||||
embeddedTreeOnce.Do(func() {
|
||||
embeddedTree, embeddedTreeErr = LoadPolicyTreeFromFS(defaultsTreeFS, "defaults")
|
||||
})
|
||||
return embeddedTree, embeddedTreeErr
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -27,14 +76,19 @@ var (
|
|||
embeddedDefaultsErr error
|
||||
)
|
||||
|
||||
// EmbeddedDefaults returns the parsed embedded defaults ZddcFile,
|
||||
// memoised. Parse errors surface on the first call and are sticky.
|
||||
// EmbeddedDefaults returns the embedded defaults assembled from the per-depth
|
||||
// tree into the single nested ZddcFile the cascade walker consumes, memoised.
|
||||
//
|
||||
// The cascade walker (EffectivePolicy) consults this as the bottom-
|
||||
// most level unless an on-disk .zddc up the chain sets `inherit: false`.
|
||||
// The cascade walker (EffectivePolicy) consults this as the bottom-most level
|
||||
// unless an on-disk .zddc / .zddc.zip up the chain sets `inherit: false`.
|
||||
func EmbeddedDefaults() (ZddcFile, error) {
|
||||
embeddedDefaultsOnce.Do(func() {
|
||||
embeddedDefaults, embeddedDefaultsErr = parseBytes(defaultsBytes)
|
||||
tree, err := EmbeddedPolicyTree()
|
||||
if err != nil {
|
||||
embeddedDefaultsErr = err
|
||||
return
|
||||
}
|
||||
embeddedDefaults = tree.Assemble()
|
||||
})
|
||||
return embeddedDefaults, embeddedDefaultsErr
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,312 +0,0 @@
|
|||
# defaults.zddc — embedded baseline configuration for every ZDDC
|
||||
# deployment. Baked into the binary via //go:embed in defaults.go,
|
||||
# loaded as the bottom-most level of the cascade. Operators override
|
||||
# at the on-disk root /.zddc (or any deeper level); to ignore this
|
||||
# file entirely, set `inherit: false` on an on-disk .zddc.
|
||||
#
|
||||
# To export an editable copy for an operator:
|
||||
#
|
||||
# zddc-server show-defaults > /var/lib/zddc/root/.zddc
|
||||
#
|
||||
# That places this file at the on-disk root, where the operator can
|
||||
# edit it freely. The new file then takes the place of the embedded
|
||||
# one (both contribute to the cascade, on-disk wins per-field).
|
||||
|
||||
title: "ZDDC"
|
||||
|
||||
# Empty acl at this layer — rules come from on-disk .zddc files above.
|
||||
# A deployment with no on-disk root .zddc grants no access (consistent
|
||||
# with prior behaviour); operators bootstrap by editing the root file.
|
||||
acl:
|
||||
permissions: {}
|
||||
|
||||
# ── Standard roles ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# Three roles ship empty (no members) — a fresh deployment grants
|
||||
# nothing until an operator populates them. Membership UNIONS across
|
||||
# the cascade; use `reset: true` at a subtree to start fresh.
|
||||
#
|
||||
# document_controller — owns the committed record and the party
|
||||
# registry. They:
|
||||
# - register parties: a party EXISTS iff ssr/<party>.yaml exists,
|
||||
# and the DC creates it (rwc at ssr/). This is the single
|
||||
# source of truth for party existence.
|
||||
# - file write-once into the WORM archive: read + create at
|
||||
# archive/<party>/received and issued via the worm: list (the
|
||||
# WORM mask strips w/d/a; create survives only for listed
|
||||
# principals). archive/ also grants rwc so the DC can create
|
||||
# party record dirs.
|
||||
# - rwcda across the live workspaces (incoming/working/staging/
|
||||
# reviewing), restated per-peer so a DC matched by the
|
||||
# project_team wildcard keeps full authority via within-level
|
||||
# union.
|
||||
# NOT a subtree-admin anywhere — no admins: entry. DCs cannot
|
||||
# bypass WORM (only worm-create); admin elevation is reserved for
|
||||
# the root admins: list (the human escape hatch for mis-filed
|
||||
# documents or recovery).
|
||||
#
|
||||
# project_team — everyone working on a project. Read across the
|
||||
# project, with a one-way ratchet through the live workspaces:
|
||||
# working/ cr create + read; auto_own gives the creator
|
||||
# rwcda inside the party folder they make
|
||||
# staging/ cr drop + read, no modify after the drop
|
||||
# reviewing/ cr create + read review iterations
|
||||
# incoming/ r counterparty's drop zone (observe)
|
||||
# archive/ r the committed record (received/issued), WORM
|
||||
# ssr/mdl/rsk r registry + registers (the DC maintains them)
|
||||
# Each handoff drops the role's modify rights for the previous
|
||||
# stage.
|
||||
#
|
||||
# observer — pure read-only across the project; no create anywhere.
|
||||
# Intended for auditors, regulators, and external read-only
|
||||
# viewers who must not contribute content.
|
||||
roles:
|
||||
document_controller:
|
||||
members: []
|
||||
project_team:
|
||||
members: []
|
||||
observer:
|
||||
members: []
|
||||
|
||||
# Universal tool baseline. archive (record browser), browse (file
|
||||
# tree, hosts the in-place markdown editor), and landing (project
|
||||
# picker) work everywhere. Each peer below adds its own tools
|
||||
# (transmittal in staging/, tables in mdl/rsk/ssr, etc.). available_tools
|
||||
# UNIONS across the cascade — leaf restrictions don't drop ancestor
|
||||
# entries — so this baseline propagates to every descendant.
|
||||
available_tools: [archive, browse, landing]
|
||||
|
||||
# ── The slash / no-slash routing convention ────────────────────────────────
|
||||
#
|
||||
# Every directory URL has two forms:
|
||||
#
|
||||
# <dir>/ (trailing slash) → `dir_tool` — the directory view
|
||||
# (defaults to `browse`, the file-tree
|
||||
# navigator; you rarely set it).
|
||||
# <dir> (no slash) → `default_tool` — the specialized app
|
||||
# for this folder (archive, transmittal,
|
||||
# tables). If a folder declares no
|
||||
# default_tool, the no-slash form 302s
|
||||
# to the slash form.
|
||||
#
|
||||
# JSON listing requests are unaffected — they always get the raw
|
||||
# directory listing, so the browse SPA (and any client) can enumerate
|
||||
# entries regardless of dir_tool/default_tool. Both keys cascade
|
||||
# leaf→root.
|
||||
#
|
||||
# ── Canonical project structure (top-level party peers) ─────────────────────
|
||||
#
|
||||
# A project is a top-level directory. Under it sit a FLAT set of
|
||||
# physical, party-partitioned peers — there are no virtual aggregators:
|
||||
#
|
||||
# archive/<party>/{received,issued}/ the committed record. PURE
|
||||
# WORM (one rule on archive/, no
|
||||
# exceptions): write/delete
|
||||
# stripped for all; create only
|
||||
# for document_controller (the
|
||||
# worm: list); admins bypass.
|
||||
# Party record dirs appear on the
|
||||
# first filing.
|
||||
# incoming/<party>/ counterparty drop zone
|
||||
# reviewing/<party>/<tracking>/ we review their submission
|
||||
# working/<party>/ our drafts (edit-history on)
|
||||
# staging/<party>/<tracking>/ assemble transmittals
|
||||
# mdl/<party>/*.yaml master document list (tables)
|
||||
# rsk/<party>/*.yaml risk register (tables)
|
||||
# ssr/<party>.yaml submittal status register — AND
|
||||
# the AUTHORITATIVE PARTY REGISTRY
|
||||
#
|
||||
# Party registry: `ssr/<party>.yaml` existence is the SINGLE source of
|
||||
# truth for "party <party> exists". Creating it (rwc at ssr/, via the
|
||||
# SSR form) is how a party is born. Every OTHER peer carries
|
||||
# `party_source: ssr`, so you cannot create <peer>/<party>/… — archive
|
||||
# filing included — until the ssr row exists; the server 409s otherwise.
|
||||
# ssr/ itself has no party_source (it is the source).
|
||||
#
|
||||
# mdl/ and rsk/ AGGREGATE: the peer root renders ALL parties in one
|
||||
# table (a $party column derived from the real subdir), <peer>/<party>/
|
||||
# shows that party's rows. ssr/ aggregates naturally (one flat file per
|
||||
# party). $party is a real directory level, not a synthesized column.
|
||||
#
|
||||
# Mkdir at the project root is restricted to the peer names above plus
|
||||
# system (_/.-prefixed) names (see handler/fileapi.go). Nothing here
|
||||
# needs to exist on disk — the cascade resolves behaviour so a fresh
|
||||
# project lands on usable empty views at every well-known URL. Operators
|
||||
# override by mirroring this structure in an on-disk .zddc.
|
||||
|
||||
paths:
|
||||
# First segment under root is the project name; "*" matches any.
|
||||
"*":
|
||||
# Project-scoped baseline ACL. project_team and observer read across
|
||||
# the project; document_controller gets read + overwrite-existing.
|
||||
# None gets `c` here — create is granted only at the specific peers
|
||||
# below (archive/, ssr/, and the workspaces).
|
||||
acl:
|
||||
permissions:
|
||||
project_team: r
|
||||
observer: r
|
||||
document_controller: rw
|
||||
paths:
|
||||
# ── The committed record: pure WORM ─────────────────────────
|
||||
archive:
|
||||
default_tool: archive
|
||||
# A record can only be filed for a registered party.
|
||||
party_source: ssr
|
||||
# The ONE WORM rule. Cascades to <party>/{received,issued}:
|
||||
# write/delete stripped for everyone; create survives only for
|
||||
# document_controller; admins bypass (the escape hatch).
|
||||
worm: [document_controller]
|
||||
# rwc so a DC can create party record dirs (WORM masks w/d to
|
||||
# leave read + write-once-create).
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwc
|
||||
|
||||
# ── Authoritative party registry + submittal status register ─
|
||||
ssr:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
# NO party_source — ssr/ IS the source of party existence.
|
||||
# rwc: a DC registers a party by creating ssr/<party>.yaml and
|
||||
# maintains its status (overwrite). Delete (de-register) is left
|
||||
# to admins so a party with archived records is never orphaned.
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwc
|
||||
history: true
|
||||
records:
|
||||
"*.yaml":
|
||||
field_defaults:
|
||||
kind: SSR
|
||||
locked: [kind]
|
||||
|
||||
# ── Inbound workspace: counterparty drop zone ───────────────
|
||||
incoming:
|
||||
default_tool: classifier
|
||||
available_tools: [classifier]
|
||||
party_source: ssr
|
||||
# The other party's DC uploads here (a deployment grants them
|
||||
# cr, e.g. acl: { permissions: { "*@acme.com": cr } } at
|
||||
# incoming/Acme/.zddc); OUR DC QCs via classifier and moves to
|
||||
# archive/<party>/received. project_team has read only (observe).
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
paths:
|
||||
"*": # incoming/<party>
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
|
||||
# ── Inbound workspace: review of their submission ───────────
|
||||
reviewing:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
party_source: ssr
|
||||
# The Plan-Review composite endpoint scaffolds a folder here per
|
||||
# submittal under review, with a .zddc carrying received_path
|
||||
# back to the canonical record in archive/<party>/received.
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
paths:
|
||||
"*": # reviewing/<party>
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
|
||||
# ── Outbound workspace: our drafts (edit-history on) ────────
|
||||
working:
|
||||
default_tool: browse
|
||||
available_tools: [browse, classifier]
|
||||
party_source: ssr
|
||||
# Subtree-inheriting: every markdown save under working/ is
|
||||
# snapshotted to .zddc.d/history/<stem>/ with a server-stamped
|
||||
# audit line. Reads of recorded history never require this flag.
|
||||
history: true
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
paths:
|
||||
"*": # working/<party> — auto-owned by its creator
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
|
||||
# ── Outbound workspace: assemble transmittals ───────────────
|
||||
staging:
|
||||
default_tool: transmittal
|
||||
available_tools: [transmittal, classifier]
|
||||
party_source: ssr
|
||||
# project_team drops files (cr); after the drop the doc-control
|
||||
# workflow owns it. DC gets rwcda — `d` for the cut to issued/,
|
||||
# `a` so Plan Review can write staging/<tracking>/.zddc.
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
paths:
|
||||
"*": # staging/<party>
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
|
||||
# ── Master document list (aggregates across parties) ────────
|
||||
mdl:
|
||||
default_tool: tables # peer root: all-parties table
|
||||
available_tools: [tables]
|
||||
party_source: ssr
|
||||
history: true
|
||||
# The DC maintains the deliverables register (create/edit/delete
|
||||
# rows). project_team reads it (inherited from the project level).
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
# field_codes: constrain tracking-number components here (or
|
||||
# higher in the cascade). Three kinds — enum / pattern / free;
|
||||
# map-merge across levels. originator is folder-bound (below),
|
||||
# so it is not listed here. Example:
|
||||
# field_codes:
|
||||
# discipline: { kind: enum, codes: { EL: Electrical, ME: Mechanical } }
|
||||
# sequence: { kind: pattern, pattern: "[0-9]{4}" }
|
||||
paths:
|
||||
"*": # mdl/<party>: that party's rows, flat
|
||||
default_tool: tables
|
||||
# MDL records: each .yaml is an independent deliverable with
|
||||
# its own composed tracking number. originator is the party
|
||||
# folder (the record's own dir, distance 0 above
|
||||
# mdl/<party>/<file>.yaml) and renders read-only — the folder
|
||||
# is the single source of truth for the originator code.
|
||||
#
|
||||
# To add project-wide components (phase, area, …), override
|
||||
# filename_format here AND mdl/<party>/{form,table}.yaml.
|
||||
records:
|
||||
"*.yaml":
|
||||
folder_fields:
|
||||
originator: 0
|
||||
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}"
|
||||
|
||||
# ── Risk register (aggregates across parties) ───────────────
|
||||
rsk:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
party_source: ssr
|
||||
history: true
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
paths:
|
||||
"*": # rsk/<party>
|
||||
default_tool: tables
|
||||
# RSK records: each .yaml is a row of a parent rsk-type
|
||||
# deliverable; the server auto-assigns -{row} within the
|
||||
# row-scope group on POST-create. originator is folder-bound
|
||||
# to the party folder, same as MDL.
|
||||
records:
|
||||
"*.yaml":
|
||||
folder_fields:
|
||||
originator: 0
|
||||
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}-{row}"
|
||||
field_defaults:
|
||||
type: RSK
|
||||
locked: [type]
|
||||
row_field: row
|
||||
row_scope_fields: [originator, project, discipline, type, sequence, suffix]
|
||||
15
zddc/internal/zddc/defaults/.zddc
Normal file
15
zddc/internal/zddc/defaults/.zddc
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Embedded default policy — site root (mount point of the default tree).
|
||||
# The bottom of every cascade unless an operator .zddc / .zddc.zip overrides.
|
||||
# Authored per-depth; the `_any_` directory maps to the `*` (any-segment)
|
||||
# wildcard when packaged into defaults.zddc.zip.
|
||||
title: "ZDDC"
|
||||
acl:
|
||||
permissions: {}
|
||||
roles:
|
||||
document_controller:
|
||||
members: []
|
||||
project_team:
|
||||
members: []
|
||||
observer:
|
||||
members: []
|
||||
available_tools: [archive, browse, landing]
|
||||
21
zddc/internal/zddc/defaults/_any_/.zddc
Normal file
21
zddc/internal/zddc/defaults/_any_/.zddc
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Project level (any project name): read across the project; create only at the
|
||||
# specific peers below — none gets `c` here.
|
||||
acl:
|
||||
permissions:
|
||||
project_team: r
|
||||
observer: r
|
||||
document_controller: rw
|
||||
|
||||
# Friendly labels for the canonical project peers. On-disk names stay
|
||||
# simple/lowercase; clients render these (listing display_name) in their
|
||||
# place. Cascade-merged + per-key overridable, so an operator can rename
|
||||
# any label in an on-disk project .zddc without renaming the folder.
|
||||
display:
|
||||
archive: Archive
|
||||
incoming: Incoming
|
||||
working: Working
|
||||
staging: Staging
|
||||
reviewing: Reviewing
|
||||
mdl: Master Deliverables List
|
||||
rsk: Risk Register
|
||||
ssr: "Supplier/Subcontractor Status Report"
|
||||
7
zddc/internal/zddc/defaults/_any_/archive/.zddc
Normal file
7
zddc/internal/zddc/defaults/_any_/archive/.zddc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# The committed record: pure WORM. Cascades to <party>/{received,issued}.
|
||||
default_tool: archive
|
||||
party_source: ssr
|
||||
worm: [document_controller]
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwc
|
||||
6
zddc/internal/zddc/defaults/_any_/incoming/.zddc
Normal file
6
zddc/internal/zddc/defaults/_any_/incoming/.zddc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
default_tool: classifier
|
||||
available_tools: [classifier]
|
||||
party_source: ssr
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
2
zddc/internal/zddc/defaults/_any_/incoming/_any_/.zddc
Normal file
2
zddc/internal/zddc/defaults/_any_/incoming/_any_/.zddc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
auto_own: true
|
||||
drop_target: true
|
||||
8
zddc/internal/zddc/defaults/_any_/mdl/.zddc
Normal file
8
zddc/internal/zddc/defaults/_any_/mdl/.zddc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
party_source: ssr
|
||||
history: true
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
project_team: rwc
|
||||
6
zddc/internal/zddc/defaults/_any_/mdl/_any_/.zddc
Normal file
6
zddc/internal/zddc/defaults/_any_/mdl/_any_/.zddc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
default_tool: tables
|
||||
records:
|
||||
"*.yaml":
|
||||
folder_fields:
|
||||
originator: 0
|
||||
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}"
|
||||
7
zddc/internal/zddc/defaults/_any_/reviewing/.zddc
Normal file
7
zddc/internal/zddc/defaults/_any_/reviewing/.zddc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
party_source: ssr
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
2
zddc/internal/zddc/defaults/_any_/reviewing/_any_/.zddc
Normal file
2
zddc/internal/zddc/defaults/_any_/reviewing/_any_/.zddc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
auto_own: true
|
||||
drop_target: true
|
||||
8
zddc/internal/zddc/defaults/_any_/rsk/.zddc
Normal file
8
zddc/internal/zddc/defaults/_any_/rsk/.zddc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
party_source: ssr
|
||||
history: true
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
project_team: rwc
|
||||
11
zddc/internal/zddc/defaults/_any_/rsk/_any_/.zddc
Normal file
11
zddc/internal/zddc/defaults/_any_/rsk/_any_/.zddc
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
default_tool: tables
|
||||
records:
|
||||
"*.yaml":
|
||||
folder_fields:
|
||||
originator: 0
|
||||
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}-{row}"
|
||||
field_defaults:
|
||||
type: RSK
|
||||
locked: [type]
|
||||
row_field: row
|
||||
row_scope_fields: [originator, project, discipline, type, sequence, suffix]
|
||||
12
zddc/internal/zddc/defaults/_any_/ssr/.zddc
Normal file
12
zddc/internal/zddc/defaults/_any_/ssr/.zddc
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Authoritative party registry + submittal status register. NO party_source.
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwc
|
||||
history: true
|
||||
records:
|
||||
"*.yaml":
|
||||
field_defaults:
|
||||
kind: SSR
|
||||
locked: [kind]
|
||||
7
zddc/internal/zddc/defaults/_any_/staging/.zddc
Normal file
7
zddc/internal/zddc/defaults/_any_/staging/.zddc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
default_tool: transmittal
|
||||
available_tools: [transmittal, classifier]
|
||||
party_source: ssr
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
2
zddc/internal/zddc/defaults/_any_/staging/_any_/.zddc
Normal file
2
zddc/internal/zddc/defaults/_any_/staging/_any_/.zddc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
auto_own: true
|
||||
drop_target: true
|
||||
8
zddc/internal/zddc/defaults/_any_/working/.zddc
Normal file
8
zddc/internal/zddc/defaults/_any_/working/.zddc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
default_tool: browse
|
||||
available_tools: [browse, classifier]
|
||||
party_source: ssr
|
||||
history: true
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
2
zddc/internal/zddc/defaults/_any_/working/_any_/.zddc
Normal file
2
zddc/internal/zddc/defaults/_any_/working/_any_/.zddc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
auto_own: true
|
||||
drop_target: true
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
package zddc
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEmbeddedDefaultsParse — the shipped defaults.zddc.yaml must
|
||||
// parse cleanly into a ZddcFile. Regression guard against accidental
|
||||
// YAML syntax errors in the source-of-truth file.
|
||||
// TestEmbeddedDefaultsParse — the embedded per-depth default tree must assemble
|
||||
// + parse cleanly into a ZddcFile. Regression guard against a broken member.
|
||||
func TestEmbeddedDefaultsParse(t *testing.T) {
|
||||
zf, err := EmbeddedDefaults()
|
||||
if err != nil {
|
||||
|
|
@ -20,16 +21,35 @@ func TestEmbeddedDefaultsParse(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestEmbeddedDefaultsBytesDumpable — the bytes used by the show-
|
||||
// defaults CLI must be non-empty and start with a comment so an
|
||||
// operator pasting them into a real file sees the header.
|
||||
func TestEmbeddedDefaultsBytesDumpable(t *testing.T) {
|
||||
got := EmbeddedDefaultsBytes()
|
||||
if len(got) == 0 {
|
||||
t.Fatal("EmbeddedDefaultsBytes returned empty slice")
|
||||
// TestEmbeddedDefaultsZipDumpable — the .zddc.zip emitted by show-defaults must
|
||||
// be a valid archive carrying the per-depth policy members with "*" wildcard
|
||||
// segments (no leftover _any_ placeholder).
|
||||
func TestEmbeddedDefaultsZipDumpable(t *testing.T) {
|
||||
b, err := EmbeddedDefaultsZip()
|
||||
if err != nil {
|
||||
t.Fatalf("EmbeddedDefaultsZip: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimLeft(string(got), " \t"), "#") {
|
||||
t.Errorf("expected leading comment, got: %q", string(got[:60]))
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil {
|
||||
t.Fatalf("not a valid zip: %v", err)
|
||||
}
|
||||
var hasRoot, hasWildcard bool
|
||||
for _, f := range zr.File {
|
||||
if strings.Contains(f.Name, AnyPlaceholder) {
|
||||
t.Errorf("member %q still has the _any_ placeholder; want * wildcard", f.Name)
|
||||
}
|
||||
switch f.Name {
|
||||
case ".zddc":
|
||||
hasRoot = true
|
||||
case "*/working/.zddc":
|
||||
hasWildcard = true
|
||||
}
|
||||
}
|
||||
if !hasRoot {
|
||||
t.Error("zip missing root .zddc member")
|
||||
}
|
||||
if !hasWildcard {
|
||||
t.Error(`zip missing "*/working/.zddc" member`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
|||
|
||||
// Determine if this newly-created ancestor is an auto-own
|
||||
// position and whether it should be fenced (inherit: false).
|
||||
// Resolved via the .zddc cascade — defaults.zddc.yaml
|
||||
// Resolved via the .zddc cascade — internal/zddc/defaults/
|
||||
// carries the canonical "working/staging auto-own + per-user
|
||||
// homes fenced + incoming auto-own" convention, and any
|
||||
// on-disk .zddc can override per-directory.
|
||||
|
|
|
|||
|
|
@ -31,6 +31,40 @@ func DefaultToolAt(fsRoot, dirPath string) string {
|
|||
return chain.Embedded.DefaultTool
|
||||
}
|
||||
|
||||
// DisplayAt returns the cascade-resolved `display:` map for a directory —
|
||||
// the human-friendly labels a client renders in place of on-disk child
|
||||
// names (e.g. mdl → "Master Deliverables List"). Unlike a single tool
|
||||
// name, display: is a MAP that merges across the cascade: the embedded
|
||||
// baseline is the floor, then each on-disk level overrides per key
|
||||
// (shallow→deep, so the deepest .zddc wins). Keys are lower-cased so the
|
||||
// caller's lookup is case-insensitive on the on-disk basename. Returns nil
|
||||
// when nothing declares a label. Mirrors how walker.go merges Display, but
|
||||
// resolved on demand for one path (the listing reads it per directory).
|
||||
func DisplayAt(fsRoot, dirPath string) map[string]string {
|
||||
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
merged := map[string]string{}
|
||||
add := func(m map[string]string) {
|
||||
for k, v := range m {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
merged[strings.ToLower(strings.TrimSpace(k))] = v
|
||||
}
|
||||
}
|
||||
add(chain.Embedded.Display) // embedded baseline (the defaults tree)
|
||||
for i := 0; i < len(chain.Levels); i++ {
|
||||
add(chain.Levels[i].Display) // on-disk overrides, shallow→deep
|
||||
}
|
||||
if len(merged) == 0 {
|
||||
return nil
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// DirToolAt returns the cascade-resolved tool name served at the
|
||||
// directory's TRAILING-SLASH URL form. Walks chain.Levels leaf→root
|
||||
// (then the embedded defaults), returning the first non-empty
|
||||
|
|
@ -347,7 +381,7 @@ func ChildrenDeclaredAt(fsRoot, dirPath string) []string {
|
|||
// (received, issued) — or "" if the path is not at a canonical slot.
|
||||
//
|
||||
// Detection is structural against the flat-peer layout declared in
|
||||
// defaults.zddc.yaml:
|
||||
// internal/zddc/defaults/:
|
||||
//
|
||||
// - second-level <project>/<peer> for any top-level peer.
|
||||
// - third-level <project>/<peer>/<party> reports its peer (slot) for
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue