Compare commits

...

37 commits

Author SHA1 Message Date
d5ce4e1230 chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 8s
2026-06-08 06:50:27 -05:00
0d7feb3468 fix(browse,server): sync .zddc lint keys, viewable schema pill, accurate virtual-source text
Three .zddc previewer fixes reported against the browse YAML editor:

- Lint no longer flags valid keys. browse/js/preview-yaml.js TOP_KEYS had
  drifted from the Go decoder (zddc/internal/zddc/file.go): party_source,
  history, history_globs, records, auto_own_roles, received_path,
  planned_response_date, planned_review_date, field_codes were all reported
  as "unknown key". Add them with appropriate type tags plus an 'object'
  case in checkValue for the free-form maps (records, field_codes).

- The ".zddc schema" pill is now clickable (↗) — opens the canonical JSON
  Schema the lint mirrors at /.api/zddc-schema (no-auth, read-only).

- The synthetic virtual-.zddc header comment named an internal source path
  (internal/zddc/defaults/'s paths: tree) that an operator can't act on. It
  now names the operator-facing artifact: the built-in defaults bundle,
  exportable/overridable as a root .zddc.zip via `zddc-server show-defaults`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:00:02 -05:00
a0e467200e fix(browse): shorten the .zddc form intro so it doesn't dominate the space above Title
The intro <p class="help"> was a 2–3 sentence paragraph; in a narrower
preview pane it wrapped to ~9 lines (~170px tall), pushing the Title field
far down. It was also redundant with the read-only "Structure & advanced"
section + the "Edit raw YAML" button. Tighten it to one concise line
("Project options. Structural keys are read-only — use Edit raw YAML."):
now 20px wide / 41px on a narrow pane (was up to ~170px).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:11:28 -05:00
14f8780dc5 fix(tables,profile): define spacing tokens (gutters + cell padding); profile rows open the .zddc form
The tables tool referenced --spacing-sm/md/lg (14×) but they were never
defined and used no fallbacks, so every padding/margin/gap collapsed to 0 —
table cells had no vertical padding and the table sat flush to the viewport
edges. Define --spacing-sm/md/lg (+ alias the --color-*/--radius-sm names the
tool uses) in shared/base.css, and give .table-main a clear left/right gutter
(padding: md lg). Fixes every tables view (profile/tokens/diagnostics/mdl).

Profile: clicking a project (or admin-subtree) row now opens that scope's
.zddc INFO FORM in the browse editor (via the ?file=.zddc deep link →
selects + previews the .zddc → schema-driven Title/Roles/Admins form),
instead of navigating into the project's files. Diagnostic rows still link
to their endpoints.

Validated in a containerized browser: 24px side gutters + padded rows;
clicking Proj → /Proj/?file=.zddc → the .zddc form editor. Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:13:54 -05:00
1b9fec66b3 feat(browse): generalize the in-pane tool embed to fold classifier/transmittal/archive
grid.js was classifier-only; make it embed ANY embeddable full-page tool the
cascade resolves as default_tool — classifier (incoming/), transmittal
(staging/), archive (the index) — as an iframe scoped to the current dir
(<dir>/<tool>.html). This is the browse-as-shell bridge from the ADR: browse
stays the top-level app and the heavy tools open in-pane (the gridView), so
navigating to staging/ or archive/ inside browse shows transmittal/archive
without leaving the shell, with ?view=browse falling back to the folder
listing (and the standalone tools still served directly at the no-slash URL).

EMBEDDABLE = {classifier, transmittal, archive}; tables/forms embed in the
preview pane instead, landing/browse don't self-embed. resolveViewMode keys
off grid.availableHere() (now generic). Validated in a containerized browser:
each dir embeds its tool, ?view=browse overrides to the listing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:17:41 -05:00
d183de434d feat(profile): render the admin diagnostics (config/logs/whoami) as chrome'd tables
The profile page links to /.profile/{config,logs,whoami}, which returned raw
JSON — so a browser click landed on raw JSON. Render them through the tables
engine instead (header chrome + sortable/filterable columns), content-
negotiated: browsers (Accept: text/html) get the table; scripts (Accept:
application/json) still get the unchanged JSON. New serveDiagTable helper +
kvRow/kvColumns: logs → time/level/message/detail rows (newest first);
config + whoami → Field/Value rows. Dropped the deep effective-policy row
from the profile table (kept JSON-only, not linked).

Extends api-actions.js with a `readOnly` context flag so a server-injected
read-only table (no apiActions) still hides the file-model toolbar buttons
(+ Add row / Save). Export CSV stays.

Completes the bespoke-server-page → tables-engine consolidation: tokens,
profile, and the three admin diagnostics now all render declaratively with
shared chrome; per-role gating stays server-side (diagnostics are elevated-
super-admin only). Full Go suite green; verified in a containerized browser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:23:23 -05:00
0c6396d246 docs+test: document the apiActions / server-injected-table primitive
Capture the mechanism the tokens + profile consolidation now rests on:
AGENTS.md gains a "Server-injected collections (apiActions)" section under
the Tables system (pre-assembled #table-context + the create/deleteRow/
rowNav layer, with server-side per-role gating), and the ARCHITECTURE ADR
marks step 2 done (/.tokens + /.profile render via the engine) and flags
that the remaining folds (archive/landing/transmittal) are feature-rich
PLUGIN migrations — not quick tables-fications.

Adds TestBuildTokensTableContext locking the contract: only the caller's
own tokens become rows, each row carries its id for the delete action, and
apiActions wires create (one-time secret) + per-row delete to /.api/tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:55:20 -05:00
d9256050d2 feat(profile): render /.profile via the tables engine (access + create + diagnostics)
Retire the bespoke profile page. /.profile now renders through the shared
tables engine (header chrome incl. the profile menu) from a server-injected
context: the caller's "Effective access" — projects + admin subtrees — as
clickable rows (rowNav opens each), identity in the description, and an
apiActions "+ New project" (name → POST /.profile/projects, gated on
can_create_project; roles are added afterward by editing the project's
.zddc, which is now standing-editable). Super-admin diagnostics
(config/logs/whoami/effective-policy) stay discoverable as rows linking to
their unchanged endpoints — gated on IsSuperAdmin so a non-admin's context
never even names them.

Dropped as redundant/niche: the in-page theme picker (the header has the
theme button), the localStorage inspector, and the "editable .zddc" links
(those files are now standing-editable in browse).

Extends the generic apiActions layer (tables/js/api-actions.js) with
`fixed` constant fields (e.g. parent="/"), `required` field validation, and
`rowNav` clickable rows (capture-phase, so it beats the editor's per-cell
handlers). Rewrote TestServeProfileHTMLLayered to the new model (per-role
context correctness: no admin leak; super-admin diagnostics present) and
dropped the now-dead stripTemplates helper. Validated in a containerized
browser; full Go suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:07:56 -05:00
2a05b7716c feat(tokens): render /.tokens via the tables engine + generic apiActions layer
Retire the bespoke, chrome-less /.tokens page. It now renders through the
shared tables engine — getting the standard header (logo, theme, profile
menu) + declarative columns/filters for free — from a server-injected,
pre-assembled #table-context built from the user's tokens (Store.List).

New, reusable "tables over an API collection" primitive (tables/js/
api-actions.js): when the injected context carries an `apiActions` block,
it drives create (a modal form → POST, surfacing the one-time secret) and
per-row delete (→ DELETE) against a REST endpoint, and hides the file-model
toolbar affordances (+ Add row / Save). It deliberately does NOT touch the
file-save/row-ops machinery (ETag/conflict/row-file writes), so the secrets
surface stays on the existing tested /.api/tokens endpoints.

Server: handler.injectTableContextObj injects an arbitrary pre-assembled
context; EmbeddedTablesHTML() exposes the renderer to sibling handlers;
ServeTokensPage builds the token context (+ apiActions for /.api/tokens)
and serves the tables HTML, falling back to the legacy skeleton only when
the store or the tables renderer is unavailable.

This is the first dynamic/virtual-record collection rendered by the same
declarative engine + chrome as on-disk tables — no bespoke page. Validated
end-to-end in a containerized browser (list + create→secret + revoke);
tests/tokens.spec.js updated to the new UI; full Go suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:09:40 -05:00
76087c861c docs(adr): browse-as-shell with preview-pane plugins (target architecture)
Document the agreed direction: browse becomes the single shell (header +
tree + preview pane), content tools become preview-pane plugins, and
server features (account menu, permissions) are progressive enhancement —
not a server-rendered header wrapping an iframed browse. Sketches the
plugin contract (handles/render/dispose + the ctx capability object that
abstracts server-vs-local read/write/verbs) and the incremental migration
path. Captures the model settled on with the user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:44:39 -05:00
ef849ab3fa feat(shared): replace floating elevation toggle with a header profile menu
Drop the bottom-right floating "Admin mode" switch in favour of a proper
account menu in the header's upper-right (every tool's .header-right).

New shared/profile-menu.{js,css}: a circular avatar button (email initial)
opening a dropdown with the signed-in email, an "Admin mode" item (only for
can_elevate principals — drives elevation.setOn/setOff, drops on leave),
Profile (/.profile), and Access tokens (/.tokens). The panel is portaled to
<body> + position:fixed so it overlays content reliably regardless of the
app's stacking contexts; the button shows a red ring while elevated.

No logout: authentication is the upstream proxy's concern (oauth2-proxy /
Authelia) — ZDDC owns no session, so the menu doesn't render sign-out.

elevation.js keeps the state machine (cookie, armed banner/frame, ephemeral
pagehide-clear, zddc:elevationchange, ?admin= URL) but no longer renders any
control — the profile menu is the UI. elevation.css drops the floating-
toggle styles (keeps banner + frame). All 7 templates drop the dead
elevation-toggle placeholder; all 7 build.sh bundle profile-menu.{js,css}.

Validated in a containerized browser: menu items, links, elevation arming +
armed ring, dropdown overlays content, no floating toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:43:43 -05:00
7f5a54f845 feat(server): cascade-resolved display: labels for the canonical project peers
A directory's display: map (on-disk child name → friendly label) was read
only from the immediate on-disk .zddc, so the baked-in defaults could never
supply labels. Resolve it through the cascade instead (new zddc.DisplayAt:
embedded baseline + ancestor + on-disk overrides, deepest wins per key) and
declare the labels in the embedded project-level default
(defaults/_any_/.zddc):

  archive→Archive, incoming→Incoming, working→Working, staging→Staging,
  reviewing→Reviewing, mdl→"Master Deliverables List", rsk→"Risk Register",
  ssr→"Supplier/Subcontractor Status Report".

On-disk names stay simple/lowercase; clients render display_name in their
place (browse already does). An operator's on-disk display: still wins per
key. Drops the now-unused readDisplayMap (folded into DisplayAt). Verified
in a containerized browser: /Proj/ shows all eight friendly labels, with
mdl/rsk/ssr still rendered as click-to-table leaves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:48:46 -05:00
18d3aaebf0 docs: update the admin/elevation model to standing config-edit + additive sudo
CLAUDE.md/AGENTS.md/ARCHITECTURE.md described the old "elevation gates
.zddc edit" model. Rewrite the elevation sections to the current model:
config-edit is a STANDING permission (IsConfigEditor — subtree admin or
`a` verb, no toggle, VerbA granted above the WORM clamp); elevation is
purely additive (IsActiveAdmin = admin AND Elevated, single bypass site,
guards WORM/destructive/out-of-scope only); the elevate cookie is now a
per-page session cookie armed by the on-page bottom-right toggle; the
.zddc.zip bundle is visible+editable to config-editors of its dir (not
wide-read); .zddc.d secrets stay locked; config is transparent via
read-ACL'd ServeZddcFile. Drops stale references (CanEditZddc, Max-Age=1800,
header-toggle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:37:48 -05:00
9b20e4451f feat(browse): render default_tool=tables dirs (mdl/rsk/ssr) as click-to-table leaves
A directory whose cascade default_tool is "tables" (mdl/rsk/ssr and any
operator-configured table dir) now shows in the browse tree as a leaf with
a table icon — no expand chevron — and clicking it opens the tables tool in
the preview pane (an iframe scoped to the dir, mirroring grid.js's
classifier embed) instead of expanding/navigating into the folder.

Detection rides the cascade, not hardcoded names: the directory listing now
carries a per-entry default_tool hint (listing.FileInfo.DefaultTool, set via
zddc.DefaultToolAt for both on-disk children and the virtual canonical peers
mdl/rsk/ssr). Browse's util.isTableLeaf(node) keys off it; tree.js renders
the leaf, events.js routes its click/Enter to the preview (excluding it from
expand/navigate), and preview.js renders the iframe at the dir's NO-SLASH
URL (the default_tool route — <dir>/tables.html 404s for a virtual dir).

Server mode only (the hint is absent on file://, so offline folders stay
ordinary expandable dirs). Validated end-to-end in a containerized browser:
mdl/rsk/ssr are leaves, normal folders keep their chevrons, and clicking mdl
loads the tables view inline without navigating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:18:47 -05:00
70591dcfa6 feat(server,browse): .zddc.zip bundle visible+editable to standing config-editors
The config bundle followed the old elevation gate: only an *elevated* admin
could browse or edit it. Bring it in line with the standing config-edit
model — a subtree admin / `a`-verb holder over the bundle's directory may
browse AND edit it without toggling. Elevation stays purely additive.

activeAdminForBundle → configEditorForBundle (zddc.IsConfigEditor, no
Elevated). Gates both the existence-hiding visibility check and the
ServeZipWrite path. Deliberately scoped to config-EDITORS, not all readers:
one .zddc.zip packs many subtrees' policy into a single file, so wide read
would leak a tightened subtree's rules — per-level transparency is served
by ServeZddcFile (already read-ACL'd) instead.

Client: isEditableZipMember drops the isElevated() check — the server gates
bundle visibility on config-edit authority, so if a member is visible the
session can edit it.

Tests: TestDispatchBundleAdminView now expects an un-elevated admin to SEE
the bundle (non-editor reader still 404); TestDispatchBundleAdminWrite adds
an un-elevated config-editor write.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:05:34 -05:00
bd219afeb7 feat(policy): config-edit is a standing permission, not elevation-gated
Editing a .zddc you administer no longer requires toggling admin mode.
Elevation becomes purely additive — it only adds the WORM/destructive
overrides ("things you otherwise couldn't do"), never a prerequisite for
authority you already hold.

Mechanism: a new zddc.IsConfigEditor(chain, email) reports STANDING
config-edit authority — being a subtree admin (admins: cascade) OR holding
the `a` verb — without the elevation gate. InternalDecider.Allow grants
VerbA on that basis ABOVE the WORM clamp: config is not WORM-protected
data, and VerbA only ever authorises .zddc/.zddc.zip/role mutations, never
write/delete of records (those stay clamped + elevation-gated). The full
WORM/ACL bypass (IsActiveAdmin) is unchanged — still admins: + Elevated.

This flows for free to the client: EffectiveVerbsFromChainP loops
ActionAdmin through the decider, so /.profile/access + cap.has(node,'a')
light up the .zddc form editor with no client change, and ServeZddcFile
already gates raw .zddc reads on directory read ACL (config is visible).

A standing subtree admin can thus rewrite their subtree's policy
(admins:/ACL/roles) un-elevated — bounded to their scope (authority
cascades down only, never up), logged, and unable to touch WORM data or
secrets without elevating. That's "admin of X = owns X's policy."

Tests: new TestStandingConfigEdit (decider matrix incl. WORM-transcending
config-edit + data-write still gated); updated the old "un-elevated admin
cannot edit .zddc" invariants (TruthTable, ZddcPut/DeleteMatrix,
NoSilentBypass now scoped to WORM/out-of-scope, profile PathVerbs) to the
new model. Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:00:54 -05:00
252d3f173e fix(browse): tighten vertical space above Title in the .zddc form
The first section's heading top margin (.6rem) stacked with the intro
paragraph's bottom margin (.8rem), leaving ~1.4rem of dead space above
the Title label. Drop the heading's top margin for the first section
(new `tight` flag in section()) and trim the intro's bottom margin to
.5rem. Later sections keep their inter-section gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:14:03 -05:00
35a1547d33 feat(browse): drop New folder/file toolbar buttons, lift Sort/Hidden above filter
The create actions duplicate the right-click context menu, so remove
them from the tree-pane toolbar. Reorder the toolbar so the Sort + Hidden
view controls sit ABOVE the autofilter box. Drops the now-dead toolbar
New-button click handlers and their create-gate enable/disable logic
(canCreateHere still gates the context-menu create items).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:03:25 -05:00
4dfbc44d45 feat(elevation): on-page admin-mode toggle, ephemeral per-page
Admins opt into admin powers via an on-page switch instead of only
?admin=true. The toggle renders ONLY for users the server reports
can_elevate, reusing each tool's existing header placeholder (or
creating one) and floating it bottom-right via fixed positioning.

Admin mode is now EPHEMERAL — scoped to the page you armed it on:
  - the zddc-elevate cookie is session-scoped (drops the 30-min Max-Age)
  - pagehide clears it, so navigating away / closing drops admin

Because a reload would race the pagehide-clear, every arm/drop path
(toggle, ?admin= URL, banner "Drop admin") now applies IN PLACE and
emits a `zddc:elevationchange` event. browse listens for it and
re-fetches the listing (server-computed verbs) + re-renders the open
preview, so editor affordances reflect the new elevation without a
manual reload.

Validated end-to-end in a containerized Chromium (Playwright over CDP)
against a local zddc-server: the toggle renders for can_elevate, arming
sets the session cookie + armed chrome, "Drop admin" and navigate-away
both clear it, and ?admin=true still arms via the same funnel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:29:19 -05:00
cd645c53bb feat(browse): schema-driven .zddc form view (option fields editable, structure read-only)
The primary editor for a .zddc is now a FORM, not raw YAML — so configuring a
project doesn't require understanding the cascade. preview-zddc-form.js fetches
the .zddc JSON Schema (/.api/zddc-schema) and renders:
  - OPTION fields editable — title, admins (email list), roles (per-role member
    lists, + add role). These are the "blanks an operator fills."
  - STRUCTURE + unrendered keys (paths, worm, tools, behaviors, field_codes,
    display, …) shown read-only in a collapsed "Structure & advanced" section
    (classified by the schema's x-zddc-tier).
  - An "Edit raw YAML" escape that hands off to the CodeMirror editor.

Save merges the edited option values back into the parsed document — preserving
every structure/unrendered key — and PUTs the YAML via util.saveFile, which
works for an on-disk .zddc AND a .zddc.zip bundle member (ServeZipWrite).
Edit authority is the existing gate (ActionAdmin 'a', or an editable bundle
member); non-admins get a read-only form.

Wired as the primary .zddc editor in preview.js (before the YAML plugin) and
into the unsaved-changes guard. Raw YAML remains the power-user fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:02:47 -05:00
9b9d823a67 feat(zddc): .zddc JSON Schema (machine grammar) with structure/option tiers
Authoritative machine form of the GRAMMAR.md grammar: zddc.schema.json
(draft 2020-12) describes every .zddc key with type, enum, description, and
x-zddc-tier — "structure" (the project shape an end user shouldn't change:
paths, worm, *_tool, views, available_tools, auto_own*, party_source, history*,
records, acl, created_by) vs "option" (the blanks an operator fills: roles
members, field_codes, convert, display, admins, title, planned dates). This is
the contract a future .zddc form view uses to render option fields editable and
structure fields read-only.

Embedded (ZddcSchemaBytes) and served at GET /.api/zddc-schema for the client.
Test locks the tier classification.

Scope note: the schema uses $ref (recursive paths:) + patternProperties, which
the in-tree internal/jsonschema validator doesn't support — so it drives the
form/client now; wiring it as the SERVER validator (replacing validate.go's
hand-rolled checks) needs a $ref-capable validator and is a separate decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:54:07 -05:00
7b59d82cdb feat(browse): edit .zddc.zip bundle members in-place (elevated admin)
The .zddc/markdown editors marked every zip member read-only. Add
util.isEditableZipMember (member of a .zddc.zip + session elevated) and let
those through canSave in both editors — so an elevated admin can open a bundle's
policy .zddc (or any member) and save it, which PUTs to the member URL where the
new server-side ServeZipWrite handles the in-place rewrite + in-zip history. The
server (bundle gate + active-admin) is the real authority; this just drives the
editor UX (mount editable, label "config bundle" instead of "read-only (zip)").
Content-archive members stay read-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:30:48 -05:00
fcb8fc6cf1 feat(server): edit-in-place for the .zddc.zip config bundle, with in-zip history
A zip is random-access (unlike a streamed .tgz), so a member can be rewritten
in place. ServeZipWrite (handler/zipwrite.go) handles PUT (write/create a
member) and DELETE (remove) inside the .zddc.zip bundle: read the whole archive,
snapshot the prior member into an in-zip .history/<member>/<ts> + append a
log.jsonl audit line, mutate, then write a fresh zip and atomically rename over
the original (serialized on one mutex). After a write the policy cache is
invalidated so .zddc policy members take effect immediately, and the apps.Bundle
mtime-reload picks up tool-HTML edits.

Gated to active admins and to the .zddc.zip bundle only (dispatch's bundle gate
already 404s everyone else; content zips — transmittal/WORM packages — stay
read-only and 405). Writing into the in-zip .history/ is refused (append-only).

Also fixes a read collision: a .zddc member INSIDE a zip (e.g. a policy member,
URL ".../.zddc.zip/<dir>/.zddc") was being grabbed by the raw-.zddc-view handler
and 500ing; that handler now excludes ".zip/" paths so the zip intercept serves
the member.

Tests: writer round-trip (incl. wildcard member); dispatch create+overwrite,
policy-takes-effect, in-zip history audit, read-back, non-admin 404, content-zip
405.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:28:06 -05:00
d878bc87e9 docs: drop stale apps: tool-source override (CLAUDE.md, README.md)
The apps: .zddc key (channel/version/URL fetch + _app cache) was removed; both
files still described it as the tool-source override. Replaced with the current
model: drop a real <app>.html at the path, or add an <app>.html member to a
.zddc.zip (resolution: on-disk file → .zddc.zip member → embedded; no fetch).
AGENTS.md / ARCHITECTURE.md / zddc/README.md already reflected this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:14:21 -05:00
51defb115a docs: sweep defaults.zddc.yaml → embedded default tree; show-defaults emits .zddc.zip
Post-migration doc sweep across the repo-level references: defaults.zddc.yaml
(deleted) → the embedded per-depth tree (internal/zddc/defaults/), and
`zddc-server show-defaults` now exports a .zddc.zip policy bundle (per-depth
files) rather than dumping an annotated single YAML. Updates AGENTS.md,
ARCHITECTURE.md, CLAUDE.md, README.md, zddc/README.md. (GRAMMAR.md already
updated in the phase-6 commit.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:38:30 -05:00
1e0e403f1e feat(zddc): retire defaults.zddc.yaml; .zddc.zip is the policy carrier (phase 6)
Completes the migration. The embedded per-depth tree (internal/zddc/defaults/)
is now the sole source of the shipped baseline; defaults.zddc.yaml is deleted.

  - EmbeddedDefaults() assembles the tree (no yaml). show-defaults now emits a
    .zddc.zip (per-depth, "*" wildcard members) via EmbeddedDefaultsZip() —
    operators redirect it to <ROOT>/.zddc.zip (or any directory) and edit/add/
    delete individual members.
  - Dropped EmbeddedDefaultsBytes; reworked the dumpable test to validate the
    emitted zip; removed the now-redundant tree-vs-yaml oracle (the Layer-2
    matrix is the ongoing behavioral guarantee, and it stays green).
  - Swept stale "defaults.zddc.yaml" comment references to the embedded tree.
  - GRAMMAR.md §1/§6 updated: .zddc.zip is a policy bundle mountable at ANY
    directory (subtree mount; inherit:false + acl.inherit:false = island); the
    shipped baseline is the embedded bundle at the root.

Net of the 6-phase migration: policy is per-depth .zddc files in a .zddc.zip
that an operator can drop at any level to override the cascade; the engine
(Assemble + the unchanged walker) enforces it. Full Go suite + matrix green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:35:21 -05:00
4681f2c358 feat(zddc): operator .zddc.zip mountable at any cascade level (migration phase 5)
EffectivePolicy now reads, at every directory in the walk, an optional
<dir>/.zddc.zip policy bundle: its members are loaded into a PolicyTree,
Assemble()d into a nested ZddcFile, and merged UNDER the dir's on-disk .zddc
(most-specific human edit wins). Because Assemble produces an ordinary
paths:-bearing ZddcFile, the existing walker threads the bundle's deeper members
to descendants and honors inherit:false with zero new cascade logic — the
bundle is just another per-level policy source.

So a .zddc.zip dropped at ANY directory mounts a policy subtree there; combined
with inherit:false + acl.inherit:false in its root member it's a self-contained
island that ignores the site defaults (do-something-completely-different).
Member paths use "*" wildcards, resolved by the same literal-first matching as
paths:. A tool-HTML-only bundle (no .zddc members) contributes no policy.

Test: a bundle at /Proj/special grants only *@vendor.com (rwcd at the mount, r
at "*" descendants) and, fenced, blocks the embedded project_team grant that
still applies outside the island.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:29:12 -05:00
21f6883157 feat(zddc): embed default tree + assemble into cascade (migration phases 3-4)
Phase 3 — //go:embed all:defaults bakes the per-depth default tree into the
binary; EmbeddedPolicyTree() loads it (LoadPolicyTreeFromFS, generalized to any
fs.FS — embed, disk, or zip).

Phase 4 — PolicyTree.Assemble() folds the flat per-depth tree into the single
nested paths:-bearing ZddcFile the cascade walker already consumes, so the
walker is UNCHANGED. EmbeddedDefaults() now sources from the tree via Assemble()
instead of parsing defaults.zddc.yaml.

Proven behavior-preserving: TestEmbeddedTreeMatchesYAML asserts Assemble(tree)
deep-equals the legacy parsed defaults.zddc.yaml, and the Layer-2 matrix +
full suite stay green. defaults.zddc.yaml is kept only as that test's oracle
(deleted in phase 6). This same Assemble path is what an operator .zddc.zip
mounted at any level will use next (phase 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:22:59 -05:00
7e3dbe81aa feat(zddc): policy-tree resolver + per-depth default tree (migration phases 1-2)
Foundation for replacing the single embedded defaults.zddc.yaml with a
.zddc.zip policy SUBTREE mountable at any directory. defaults.zddc.yaml stays
live and authoritative for now — this is purely additive.

Phase 1 — author the per-depth default tree under internal/zddc/defaults/, one
focused .zddc per canonical folder (root, */, */archive, */working[/*], */ssr,
*/mdl[/*], */rsk[/*], */staging[/*], */reviewing[/*], */incoming[/*]). The
`_any_` directory is the on-disk stand-in for the "*" wildcard, so the repo
holds no shell-/go:embed-hostile literal "*" directories.

Phase 2 — PolicyTree (internal/zddc/zippolicy.go): a set of .zddc documents
keyed by member dir relative to a mount point, with "*" wildcards.
resolveTreeDir does literal-first, most-specific segment matching (mirrors the
paths: cascade); Along returns the governing member at each cascade level
root→leaf; LoadPolicyTreeFromDir loads the source tree (mapping _any_ → *).
This is the engine for "drop a .zddc.zip at any level"; inherit:false in a
resolved member makes that subtree a self-contained island (existing fence
mechanism, unchanged).

Tests: resolver matching mechanics; the split tree loads with the expected keys
+ content (data-level faithfulness — full effective-policy parity is the
Layer-2 matrix once the cascade is wired in Phase 4); Along ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:40:34 -05:00
a84bdfdc58 docs(zddc): formal .zddc grammar reference
Consolidates the .zddc policy language — scattered today across ZddcFile struct
comments, defaults.zddc.yaml, and ARCHITECTURE.md — into one authoritative spec:

  - document model + cascade (levels root→leaf, virtual paths:, fences) and the
    rule that decisions resolve at the target's OWN dir (the bug class we hit);
  - the decision pipeline: active-admin bypass → WORM mask → cascade ACL, plus
    elevation + default-allow-on-empty-tree;
  - ACL composition, with the two deliberately-different rules stated plainly
    (role membership unions up the tree; permissions take the deepest match);
  - a per-key reference table (type + cascade semantics + meaning) for all ~25
    keys, including the mergeOverlay trap for adding new keys;
  - reserved namespaces (.zddc.d, .zddc.zip);
  - a reserved `when:` extension point for sandboxed, side-effect-free
    expressions (CEL/expr-lang) — the safe alternative to raw JS, complementing
    the existing OPA/Rego Decider seam;
  - validation + the two executable backings (Layer 1 engine, Layer 2 matrix).

Policy-as-data: operators express behaviour in .zddc; the app enforces. Per the
chosen direction (formalize first; sandboxed expressions for the conditional gap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:24:15 -05:00
bae8e1f79b test(policy): Layer-2 default-policy matrix — role × path × verb truth table
The executable contract for the shipped defaults (internal/zddc/defaults.zddc.yaml):
~38 cells asserting who-can-do-what across the canonical project folders, routed
through the real decider (InternalDecider: cascade + WORM mask + active-admin
bypass) evaluated at the target's logical parent — the same decision the server
makes. Locks the document-control model so a change to the defaults OR the
engine that resolves them can't silently shift access. Storage-agnostic: if the
defaults later move into a project-root .zddc.zip of per-depth .zddc files, the
test is unchanged (it asserts effective policy, not where the bytes live).

Covers: no-create-at-project-root; DC/team/observer per-peer grants (working/
staging/reviewing/incoming/ssr); team rwc on mdl/rsk; archive WORM (DC
create-once, no write/delete; others read); elevated-admin bypass vs un-elevated
no-bypass; anonymous denied. Complements Layer 1 (engine-follows-policy):
policy.TestInternalDecider_CascadeScenarios + zddc/{acl,roles,worm}_test +
policy/parity_test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:01:29 -05:00
3ac53fe894 fix(fileapi): authorize creates at the logical parent, not the nearest on-disk dir
authorizeAction walked `probe` up from the target's parent to the nearest
EXISTING directory before computing the ACL chain. For a create deep under a
not-yet-materialised canonical path — e.g. mkdir working/<party>/<name> when
working/ and working/<party>/ don't exist on disk yet — that walk skipped the
virtual working/ level and landed on the project root, where the embedded
grant is only `document_controller: rw` (no `c`). Result: a bona-fide
document_controller got 403 missing_verb=c creating in working/ (and party
registration would fail the same way on a fresh project where ssr/ doesn't
exist yet).

EffectivePolicy is virtual-path-aware — the paths: cascade resolves per-folder
behaviour for directories that don't exist on disk — so the chain must be
evaluated at filepath.Dir(absPath) directly. This applies the correct
per-peer grant (working/ → document_controller rwcda, project_team cr; ssr/ →
document_controller rwc) regardless of what's been physically created. Ancestor
restrictions (WORM zones, inherit:false fences) still apply because they cascade
through EffectivePolicy, so this is strictly more correct, never more permissive
than the cascade intends.

Regression test: a document_controller (role member, not admin, un-elevated)
registers a party and mkdirs under working/<party>/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:39:19 -05:00
9552b297e7 fix(project-create): seed role membership only; grant team rwc on mdl/rsk
My earlier create-project flow wrote per-role verb grants (project_team: rwc,
…) at the PROJECT ROOT, which cascaded create/write across the whole project —
wrong. The project root is structurally locked to canonical peers
(rejectProjectRootMkdir), and the embedded defaults already grant each role its
per-FOLDER permissions ("None gets `c` here — create is granted only at the
specific peers below").

Project-create now writes role MEMBERSHIP only (document_controller /
project_team / observer) plus admins + created_by. Membership unions across the
cascade, so naming members at the project root makes the embedded per-peer
grants apply to them. No acl.permissions is seeded (the advanced field is still
an escape hatch). The dialog's "Guests" maps to the defaults' read-only
`observer` role (was a non-existent `guest` role that hooked no grants).

Per decision, MDL & RSK are now collaboratively editable: defaults grant
project_team rwc (create + edit, no delete) at mdl/ and rsk/ alongside
document_controller rwcd — the history: audit on both covers every change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:29:34 -05:00
fbe9d11f22 feat(profile): project-create — drop parent picker, add role groups, record creator
Projects are always created at the deployment root, so the "Parent" dropdown
(and populateParentChoices) is gone — the client always POSTs parent:"/".

The Create-new-project dialog now collects members for the four project roles
— admins, document controllers, project team, guests — as simple email lists.
Server-side, each non-empty list becomes a roles:<name> entry plus a base
acl.permissions grant (document_controller→rwcd, project_team→rwc, guest→r);
an explicit advanced acl.permissions entry for the same key still wins.

The new project's .zddc now always records the creator: zf.CreatedBy = creator
email, and the creator is always included in admins: (deduped, first) so they
administer their own project from birth.

Tests: creator recorded + roles/permissions seeded; explicit permission
overrides the role default. Existing create tests still pass (creator-in-admins
is compatible with the explicit-admins-list case).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:55:55 -05:00
05e37256b7 feat(editor): add revision/status/tracking_number FM hints + filename-mismatch warning
Per review: the doctype templates render $revision$, $status$, $tracking_number$
and $title$, so they belong in the recognised front-matter list — added them
(alongside the existing title) to convert.RecognizedFrontMatter.

These four are the document's canonical identity, sourced from the ZDDC
filename. Policy (chosen): the filename WINS — the rendered doc always uses the
filename-derived value (the HTML/PDF templates read it from the filename-derived
pandoc -V flags, which override YAML metadata). Front matter must not silently
diverge, so:
  - their hints now read "set by the filename (the filename wins on mismatch)";
  - the markdown editor shows a non-blocking warning when front matter sets one
    of the four to a value differing from the filename (gated on a conventional
    ZDDC filename — non-conventional files have no canonical identity, so front
    matter stays free there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:34:28 -05:00
85e0061d6c feat(editor): hint recognized front-matter fields via server placeholder
The markdown editor's YAML front-matter pane was a bare textarea, so authors
had no way to discover the keys the converter honours — notably `doctype:`
(report|letter|specification) and `numbering:`, which have no other source.

Add a single server-side source of truth, convert.RecognizedFrontMatter() +
convert.FrontMatterPlaceholder(), and expose it as JSON at GET /.api/frontmatter
(handler.ServeFrontMatterTemplate; read-only, no auth — leaks only documented
field names). The browse editor fetches it once (server mode) and sets the
front-matter textarea's placeholder to the greyed hint, so an empty pane shows
the recognized keys with one-line hints. It's placeholder-only: it inserts
nothing, vanishes on the first keystroke, and arbitrary keys remain free —
front matter is still passed through to pandoc untouched. file:// mode shows no
placeholder (conversion is server-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:23:25 -05:00
3823946d4f feat(browse): unify export formats via one matrix; add PDF to Export menu
The Export context-menu offered only md↔docx↔html (a symmetric set), so PDF
— which the server supports only as md→pdf — was missing. The markdown
editor's DOCX/HTML/PDF buttons hardcoded their own list, so the two could
drift.

Introduce a single source of truth in download.js: EXPORT_MATRIX mirrors
zddc/internal/convert.Convert() exactly (md→docx|html|pdf, docx→md|html,
html→md|docx), exposed as download.exportTargets(ext) + download.convertUrl().
The Export submenu and the editor's buttons both consume it, so a .md file now
offers PDF in the menu and the two surfaces can never disagree. PDF stays
markdown-only (no docx→pdf / html→pdf path exists server-side).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:12:01 -05:00
113 changed files with 7620 additions and 1715 deletions

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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"

View file

@ -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>

View file

@ -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" \

View file

@ -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

View file

@ -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;

View file

@ -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
};
})();

View file

@ -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 dont 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() {

View file

@ -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
};
})();

View file

@ -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
};

View file

@ -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); } };
});
}

View file

@ -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

View file

@ -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';

View 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);

View file

@ -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).

View file

@ -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">'

View file

@ -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

View file

@ -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>

View file

@ -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"

View file

@ -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>

View file

@ -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" \

View file

@ -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>

View file

@ -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"

View file

@ -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>

View file

@ -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

View file

@ -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:

View file

@ -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
View 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
View 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();
}
})();

View file

@ -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" \

View file

@ -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
View 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 || {});

View file

@ -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>

View file

@ -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([]);
});

View file

@ -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"

View file

@ -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
View 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.

View file

@ -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

View file

@ -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

View file

@ -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)
}
}

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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())
}
}

View file

@ -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)
}

View file

@ -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)
}
}

View 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)
}
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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")

View file

@ -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 &amp; 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();
}
}

View file

@ -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")

View file

@ -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")
}
}

View 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())
}

View file

@ -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

View file

@ -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) }

View file

@ -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';

View file

@ -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

View file

@ -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"])
}
}

View file

@ -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")

View file

@ -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()

View 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)
}

View 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)
}
}

View file

@ -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"`
}

View file

@ -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:
//

View file

@ -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")
}
}

View 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)
}
}
}

View file

@ -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:

View file

@ -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

View 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)
}
}

View file

@ -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
}

View file

@ -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]

View 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]

View 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"

View 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

View file

@ -0,0 +1,6 @@
default_tool: classifier
available_tools: [classifier]
party_source: ssr
acl:
permissions:
document_controller: rwcd

View file

@ -0,0 +1,2 @@
auto_own: true
drop_target: true

View file

@ -0,0 +1,8 @@
default_tool: tables
available_tools: [tables]
party_source: ssr
history: true
acl:
permissions:
document_controller: rwcd
project_team: rwc

View file

@ -0,0 +1,6 @@
default_tool: tables
records:
"*.yaml":
folder_fields:
originator: 0
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}"

View file

@ -0,0 +1,7 @@
default_tool: browse
available_tools: [browse]
party_source: ssr
acl:
permissions:
project_team: cr
document_controller: rwcda

View file

@ -0,0 +1,2 @@
auto_own: true
drop_target: true

View file

@ -0,0 +1,8 @@
default_tool: tables
available_tools: [tables]
party_source: ssr
history: true
acl:
permissions:
document_controller: rwcd
project_team: rwc

View 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]

View 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]

View file

@ -0,0 +1,7 @@
default_tool: transmittal
available_tools: [transmittal, classifier]
party_source: ssr
acl:
permissions:
project_team: cr
document_controller: rwcda

View file

@ -0,0 +1,2 @@
auto_own: true
drop_target: true

View 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

View file

@ -0,0 +1,2 @@
auto_own: true
drop_target: true

View file

@ -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`)
}
}

View file

@ -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.

View file

@ -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