Compare commits

..

7 commits

Author SHA1 Message Date
b4d59b11ee release: v0.0.21 lockstep
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 7s
Build + deploy releases / build-and-deploy (push) Successful in 19s
Build + deploy releases / notify-chart-prod (push) Failing after 7s
2026-05-21 11:27:51 -05:00
90a31020db fix: clear the 14 stale Playwright baseline failures
Four root causes, each affecting one or more pre-existing
failures. All resolved without weakening any assertion.

1. build-label.spec.js (×4 — archive/transmittal/classifier/browse)
   The regex accepted v<X.Y.Z>-alpha|beta channel labels but not the
   -dev label modern dev builds emit. CLAUDE.md describes
   v<X.Y.Z>-dev as the canonical dev-build form. Added |dev to the
   channel alternation; tests now pass on dev builds and remain
   tight on stable cuts.

2. landing.spec.js (×8)
   SAMPLE_PROJECTS fixture pre-dated the post-reshape listing JSON
   contract. The landing's loader now filters projects on
   `is_dir: true`; the fixture didn't set it, so every entry was
   filtered out and every "renders a project table" test failed at
   the `.project-table` wait. Added `is_dir: true` (and trailing
   slash on names, matching the live server's shape) to the three
   fixture entries.

3. browse.spec.js (×1 — Download (zip))
   The #downloadZipBtn toolbar button was retired in the SPA
   overhaul (94b2e29) — Download ZIP moved to the right-click
   context menu. Test still poked the dead toolbar button. The
   picked-root folder no longer renders as a row (only its
   contents do), so the test now scopes the assertion to
   downloading a sub-folder (sub/) via right-click → Download ZIP;
   verifies the zip's entries, magic bytes, and filename.

4. tables.spec.js (×1 — Phase 3 row-blur fires PUT)
   Real bug, not a test issue. The editor's commit path tears down
   its input element (clearing focus to body) before refocusing
   the owning cell. main.js's focusout-on-#table-root handler ran
   synchronously, saw `relatedTarget=null`, treated it as "user
   left the grid", and fired flushAll() — racing the
   selection-change save that fires from the subsequent
   setSelected(r+1, c) inside the Enter handler. Net effect: two
   identical PUTs per row-blur. Deferred the focusout check to
   next tick via setTimeout(0); the cell.focus() inside the
   editor's tearDown has time to settle, and the deferred check
   sees document.activeElement still inside #table-root → skips
   the redundant flush.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:24:30 -05:00
736f422f82 fix(roles): restate document_controller at project_team slot grants
DCs are typically internal employees and ARE in project_team (when
project_team is the realistic *@example.com wildcard). The cascade's
"deepest level that has any matching principal wins" semantic means
a project_team:cr grant at the slot level would shadow the DC's
party-level rwcda — leaving DCs limited to project_team's grant.

Fix: at every slot with a project_team-specific grant, restate
document_controller's role grant. The within-level union of all
matched principals then gives the DC rwcda ∪ cr = rwcda. No cascade
semantics change; just verbose defaults.

  working/   project_team: cr, document_controller: rwcda  (new DC line)
  staging/   project_team: cr, document_controller: rwcda  (upgraded from rwcd —
                                                            adds `a` for
                                                            Plan Review's
                                                            staging/<tracking>/.zddc)
  reviewing/ project_team: cr, document_controller: rwcda  (new DC line)

Test fixture flipped from disjoint-role members to the realistic
project_team: ["*@example.com"]; verifies DC's rwcda survives the
wildcard via within-level union at each slot.

Docs updated:
  - AGENTS.md "Standard roles": describes the role-restate pattern
    + flags the internal-observer-via-wildcard caveat (operators
    needing internal observers should avoid the *@ wildcard for
    project_team).
  - ARCHITECTURE.md "Standard roles": same model description; drops
    the now-incorrect "subtree-admin of every archive/<party>/"
    line, replaces with the auto_own_roles role grant.
  - planreview_test.go fixture comment: reflects that the test
    uses root-admin to bypass ACLs, with non-root-admin DC path
    covered by standardroles tests' auto-own .zddc simulation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:03:42 -05:00
ba98b87b2a feat(roles): in-flight ratchet + auto_own_roles, drop DC subtree-admin
Two related schema/defaults changes that together replace the
admins:[document_controller] subtree-admin status with a cleaner
role-grant-via-auto-own model, and lock down the one-way handoff
through the in-flight lifecycle slots.

## New: auto_own_roles

ZddcFile.AutoOwnRoles []string is a new field on the parent's .zddc
declaring "when this directory's auto_own fires, also grant these
roles rwcda alongside the creator email". The writer
(WriteAutoOwnZddc + WriteAutoOwnZddcFenced) now takes a roles slice
and writes both the creator email AND each named role as rwcda in
the new .zddc. mergeOverlay treats AutoOwnRoles like other path-tree
contributions (leaf-wins).

The defaults' archive/<party>/ entry now sets
`auto_own_roles: [document_controller]` and drops the
`admins: [document_controller]` line:

  - When any DC mkdir's archive/<party>/, the auto-own .zddc grants
    both their email and the role rwcda. Peer DCs share full
    authority at every party without any DC needing subtree-admin
    status.
  - DCs are no longer subtree-admins anywhere. They can't bypass
    WORM (only worm-create via the worm: list) and can't reach
    inside fenced working homes. Admin elevation is reserved for
    the root admins: list.
  - Plan Review's ActionAdmin pre-flight passes for any DC via the
    role grant cascading into reviewing/ and staging/.

## In-flight ratchet (working → staging → issued)

Per-role grants at the lifecycle slots formalise a one-way handoff:

  working/   project_team: cr (create their own folders;
                              auto_own_fenced gives rwcda inside)
  staging/   project_team: cr (drop files, no modify after — the
                              "commit" step; DC takes over)
             document_controller: rwcd (transfer-to-issued needs `d`)
  reviewing/ project_team: cr (create iteration folders; auto_own
                              unfenced grants rwcda inside)
  received/  worm cr (file write-once)
  issued/    worm cr

Each handoff drops the previous role's modify rights for the slot
they pushed from. Comments in defaults.zddc.yaml document the
pattern + the "project_team drops files at staging root, never
mkdirs" convention.

## Tests

TestStandardRoles_DocControllerScopedCreate rewritten — flips
from IsSubtreeAdmin assertions to verifying:
  - rwcda at <party>/ via the auto-own .zddc (creator + role)
  - rwcda cascading to working/reviewing/ (no slot override)
  - rwcd at incoming/staging/ via explicit grants
  - cr at received/issued via WORM mask
  - IsSubtreeAdmin = false everywhere
  - DC blocked from alice's fenced working/<email>/ home

New TestStandardRoles_DocControllerMultiDC — a second DC in the
role gets the same rwcda at any party a peer created, via the role
grant in auto_own_roles.

New TestStandardRoles_ProjectTeamInFlightRatchet locks the ratchet:
project_team gets cr at working/staging/reviewing, r at incoming/
received/issued.

New TestStandardRoles_DocControllerStagingDelete confirms DC has
`d` at staging/ for the transfer-to-issued workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:51:07 -05:00
b5a725e745 feat(zddcfile): ?effective=1 composed-cascade inspection query
Add GET /<path>/.zddc?effective=1 returning JSON with the composed
ZddcFile across the full cascade plus a per-level source list. The
.zddc file itself still serves only what's defined at that level
(YAML, the source of truth); the new query is inspection-only
(JSON, never written back). The virtual .zddc body's header
comment already pointed at this URL — now it's live.

Wire shape:
  { url_path: "/Project-1/archive/Acme/working/",
    merged:  { …ZddcFile JSON, composed view… },
    sources: [ { level: -1, url: "<embedded>",
                 contributed: ["roles", "available_tools", "paths"] },
               { level: 0,  url: "/.zddc",
                 contributed: ["acl", "admins"] },
               { level: 4,  url: "/Project-1/archive/Acme/working/.zddc",
                 contributed: ["default_tool", "auto_own", …] } ] }

New zddc.EffectiveZddc(chain) walks chain.Embedded then
chain.Levels[VisibleStart..leaf] through mergeOverlay, and folds the
cross-level Roles union (via the existing lookupRoleMembers,
matching the runtime ACL evaluator's semantics). Returns
([]SourceEntry) listing each contributing level with its non-zero
top-level fields. The handler maps SourceEntry.Level to a directory
URL: -1 → "<embedded>"; 0..n → "/<seg/seg/.../>.zddc".

ACL gate is the same as the YAML view (read on the directory).
X-ZDDC-Source: virtual:effective so clients can distinguish.

Four tests cover the contract:
  - BasicCompose: alice's root grant + project_team baseline from
    embedded + the project's title all surface in merged; sources
    include -1 (embedded), 0 (root), 1 (project).
  - InheritFence: top-level inherit:false on /Closed/.zddc drops
    every ancestor including the embedded baseline from sources.
  - RoleMemberUnion: document_controller declared at root and
    project unions members in merged.roles (matches the runtime
    cross-level union the ACL evaluator performs).
  - existing virtual-body tests still pass — they hit the YAML path,
    not the JSON branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:39:29 -05:00
a0a3f8579b feat(zddcfile): virtual .zddc body = leaf cascade level as YAML
When no .zddc is on disk at the requested directory, ServeZddcFile
now renders the cascade's leaf-level ZddcFile as YAML — what
defaults.zddc.yaml's paths: tree declares for THIS exact path,
threaded through by the walker. The previous body was a comment-
only summary plus a `{}` placeholder, which forced operators to
write any override from scratch.

The .zddc file is still the single source of truth — no synthesis,
no merge: the virtual body IS the embedded subtree, marshalled in
the same shape the operator would write themselves. PUT-saving the
bytes back through the file API materialises an on-disk override
carrying exactly what the user saved. For the COMPOSED view across
the full chain, slice 2 will add ?effective=1 (returns JSON, not a
.zddc); the header comment in the virtual body points at it.

Three new test cases lock the contract:
  - VirtualDefault: at /Project/.zddc with no on-disk file, the
    embedded paths.* contribution surfaces (project_team: r,
    observer: r, archive subtree, …).
  - VirtualEmpty: at a path the embedded defaults don't declare
    (e.g. /Project/random-subfolder/.zddc), the body collapses to
    the header + an empty-document {} placeholder + an explanation
    that rules come from ancestors only.
  - VirtualPerPartyWorking: at /Project/archive/Acme/working/.zddc,
    the body carries default_tool/auto_own/drop_target and the
    classifier in available_tools — the per-party in-flight slot's
    full declaration.

Drive-by: add `omitempty` to ZddcFile.ACL, .Admins, .Title yaml
tags. Without it, the marshaled virtual body carried `acl: {}`,
`admins: []`, and `title: ""` at every nested level, drowning the
real content in noise. ParseFile is unaffected (input parsing
ignores omitempty); WriteFile's round-trip sanity check still
passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:32:15 -05:00
43c2879e9c release: v0.0.20 lockstep
Some checks failed
Build + deploy releases / build-and-deploy (push) Successful in 19s
Build + deploy releases / notify-chart-prod (push) Failing after 7s
2026-05-21 09:14:36 -05:00
26 changed files with 2483 additions and 280 deletions

View file

@ -494,14 +494,22 @@ roles:
- auditor@regulator.gov - auditor@regulator.gov
``` ```
The embedded cascade already grants `project_team: r` and `observer: r` project-wide, and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, subtree-admin of every `archive/<party>/` so they own each party's lifecycle slots — `working/`, `staging/`, `reviewing/`, `incoming/`). Populating role members lights all of that up. Plan-Review approval is part of the `document_controller` role by design — there is no separate `approver` role; two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides. The three standard roles' invariants are locked down in `zddc/internal/zddc/standardroles_test.go`. The embedded cascade already grants `project_team: r` and `observer: r` project-wide, and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, `rwcd` at `incoming/` and `staging/` for the QC + transfer workflows). When DC creates an `archive/<party>/` folder the auto-own `.zddc` written there grants both their email AND the `document_controller` role `rwcda` (via `auto_own_roles: [document_controller]` in the defaults) — so any peer DC has full authority at every party without needing subtree-admin status. Plan-Review approval is part of the `document_controller` role by design — there is no separate `approver` role; two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides. The three standard roles' invariants are locked down in `zddc/internal/zddc/standardroles_test.go`.
The in-flight lifecycle slots form a one-way ratchet:
`working/``staging/``issued/` (WORM)
Each handoff drops `project_team`'s modify rights for the slot they pushed from. At `working/` they have `cr` plus `rwcda` inside their auto-own-fenced `<email>/` home. At `staging/` they have `cr` only (drop files, no modify after). DC takes over with `rwcd` at staging and files to `issued/` via the WORM `cr` grant. Same shape on the inbound side via `incoming/``received/` (WORM).
Pick a role per persona: Pick a role per persona:
- `document_controller` — per-party records custodian; files into WORM `received/issued`, manages the `working/staging/reviewing` lifecycle, QCs the counterparty's drops in `incoming/`. - `document_controller` — per-party records custodian; files into WORM `received/issued`, manages the `working/staging/reviewing` lifecycle, QCs the counterparty's drops in `incoming/`. **NOT a subtree-admin** anywhere — authority comes purely from cascade grants (the role-level `rwcda` written by `auto_own_roles` at each party, plus explicit `rwcd` at `incoming/` and `staging/`). They cannot bypass WORM (only worm-create via the list) or reach inside fenced working homes.
- `project_team` — day-to-day contributor. Read across the project; full control of their own `archive/<party>/working/<email>/` home via auto-own with a fenced `.zddc` (`inherit: false`). - `project_team` — day-to-day contributor. Reads across the project; ratchets through the in-flight slots. Owns their `archive/<party>/working/<email>/` home via auto-own with a fenced `.zddc` (`inherit: false`).
- `observer` — pure read-only across the project. No auto-own home (the role itself has no `c` anywhere). Intended for auditors, regulators, and external read-only viewers who must not contribute content. - `observer` — pure read-only across the project. No auto-own home (the role itself has no `c` anywhere). Intended for auditors, regulators, and external read-only viewers who must not contribute content.
**Roles overlap on purpose.** DCs are typically internal employees and ARE in `project_team` (often defined as `*@example.com`). The cascade is "deepest level that has any matching principal wins for that level, with within-level UNION of all matched principals". To prevent a `project_team: cr` grant at the slot from shadowing a DC's role-level `rwcda` inherited from the party folder, the embedded defaults RESTATE `document_controller: rwcda` at every slot that has a project_team-specific grant (`working/`, `staging/`, `reviewing/`). Within-level union → DC gets `rwcda` `cr` = `rwcda`. Operators adding new slot-level project_team grants in their own `.zddc` files should follow the same pattern. (Internal `observer` users matched by the project_team wildcard would still be lifted to `cr` by the union — observer is intended for EXTERNAL auditors whose emails don't match the wildcard. Deployments with internal observers should use explicit project_team membership instead of a wildcard.)
**Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`): **Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`):
- `acl: { permissions: { <principal>: <bits> }, inherit: <bool>? }` — there is no `allow:` key; an `allow:` block parses cleanly but is silently dropped during unmarshal. Real footgun — easy to write `acl: { allow: [...] }` and assume it works. - `acl: { permissions: { <principal>: <bits> }, inherit: <bool>? }` — there is no `allow:` key; an `allow:` block parses cleanly but is silently dropped during unmarshal. Real footgun — easy to write `acl: { allow: [...] }` and assume it works.
@ -509,8 +517,18 @@ Pick a role per persona:
- Principals: email (must contain `@`), glob (`*@domain.com`), or role name (no `@`). - Principals: email (must contain `@`), glob (`*@domain.com`), or role name (no `@`).
- `roles: { <name>: { members: [...], reset: <bool>? } }` — members union across the cascade unless `reset: true`. - `roles: { <name>: { members: [...], reset: <bool>? } }` — members union across the cascade unless `reset: true`.
- `admins: [<email>, ...]` — root only; sudo-style elevation per request. - `admins: [<email>, ...]` — root only; sudo-style elevation per request.
- `auto_own: <bool>` — when true, ensure.go writes a `.zddc` granting the creator's email `rwcda` on first mkdir.
- `auto_own_fenced: <bool>` — adds `inherit: false` to the auto-own `.zddc` (private-by-default home). No effect without `auto_own: true`.
- `auto_own_roles: [<role>, ...]` — additional role names that get `rwcda` in the auto-own `.zddc`, alongside the creator's email. Lets the schema express role-level peer authority without `admins:` (which would be subtree-admin and bypass WORM/fences via elevation).
- `title:` — read only from the per-project `.zddc`; surfaces on the landing-page picker. - `title:` — read only from the per-project `.zddc`; surfaces on the landing-page picker.
**Two `inherit` scopes, one word.** `ZddcFile.Inherit` (top-level) drops the embedded baseline AND fences ancestor on-disk `.zddc` files from this point of the cascade. `ACLRules.Inherit` (nested under `acl:`) is narrower — it only fences ACL evaluation; embedded roles, paths-tree contributions, WORM lists, and other non-ACL keys still cascade through. Concretely:
- To opt out of embedded defaults at deployment, set `inherit: false` at the root `<ZDDC_ROOT>/.zddc` (top-level).
- To make a per-user home private (block ancestor read grants) but keep cascade-derived behaviour like default_tool, set `acl: { inherit: false }`. The auto-own-fenced mechanism uses this form.
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:`, `apps:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.). 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:`, `apps:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.).
### Build ### Build

View file

@ -715,7 +715,7 @@ The schema keys that drive built-in behavior:
**Standard roles.** `defaults.zddc.yaml` references three roles (all shipped empty — a fresh deployment grants nothing until an operator populates them): **Standard roles.** `defaults.zddc.yaml` 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/`, subtree-admin of every `archive/<party>/` and its in-flight slots, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow. 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. - `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. - `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.
- `observer` — pure read-only across the project. Distinct from `project_team` in that the role itself carries no `c` anywhere, so an observer can't bring a working home into existence under auto-own. Intended for auditors, regulators, and external read-only viewers who must not contribute content. - `observer` — pure read-only across the project. Distinct from `project_team` in that the role itself carries no `c` anywhere, so an observer can't bring a working home into existence under auto-own. Intended for auditors, regulators, and external read-only viewers who must not contribute content.

View file

@ -72,15 +72,27 @@
// clicked a header link, the URL bar, etc. without moving to // clicked a header link, the URL bar, etc. without moving to
// another row first). focusout fires for cell-to-cell moves // another row first). focusout fires for cell-to-cell moves
// too — relatedTarget being outside #table-root distinguishes. // too — relatedTarget being outside #table-root distinguishes.
//
// Deferred to next tick (setTimeout 0): the editor's commit
// path tears down its input element and then refocuses the
// owning cell. The remove fires focusout BEFORE the refocus
// runs, with relatedTarget=null (body briefly), so the naive
// sync check would mis-detect a "left the grid" event and
// fire flushAll redundantly alongside the selection-change
// save. Checking document.activeElement on the next tick
// gives the refocus time to settle.
const tableRoot = document.getElementById('table-root'); const tableRoot = document.getElementById('table-root');
if (tableRoot) { if (tableRoot) {
tableRoot.addEventListener('focusout', function (ev) { tableRoot.addEventListener('focusout', function (ev) {
const next = ev.relatedTarget; const next = ev.relatedTarget;
if (next && tableRoot.contains(next)) return; if (next && tableRoot.contains(next)) return;
setTimeout(function () {
if (tableRoot.contains(document.activeElement)) return;
const save = app.modules.save; const save = app.modules.save;
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) { if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
save.flushAll(); save.flushAll();
} }
}, 0);
}); });
} }

View file

@ -149,30 +149,42 @@ test.describe('Browse', () => {
await expect(page.locator('#previewBody')).toContainText('a note inside the zip'); await expect(page.locator('#previewBody')).toContainText('a note inside the zip');
}); });
test('Download (zip) bundles the current folder offline', async ({ page }) => { test('Download (zip) bundles a folder via right-click → Download ZIP', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' }); await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await page.evaluate(() => { await page.evaluate(() => {
window.__setMockDirectoryTree('mock-folder', { window.__setMockDirectoryTree('mock-folder', {
'a.txt': 'AAA', 'a.txt': 'AAA',
'sub': { 'b.txt': 'BBB', 'deep': { 'c.txt': 'CCC' } }, 'sub': {
'b.txt': 'BBB',
'deep': { 'c.txt': 'CCC' },
'.zddc': 'acl: { permissions: { "*": r } }', // hidden — must not be in the zip '.zddc': 'acl: { permissions: { "*": r } }', // hidden — must not be in the zip
'_template': { 'scaffold.txt': 'x' }, // hidden dir — must not be in the zip '_template': { 'scaffold.txt': 'x' }, // hidden dir — must not be in the zip
},
}); });
}); });
await page.locator('#addDirectoryBtn').click(); await page.locator('#addDirectoryBtn').click();
await page.waitForSelector('#browseRoot:not(.hidden)', { timeout: 10000 }); await page.waitForSelector('#browseRoot:not(.hidden)', { timeout: 10000 });
// The Download (zip) button appears once a directory is loaded. // Download ZIP lives in the row's right-click context menu —
const dlBtn = page.locator('#downloadZipBtn'); // the standalone toolbar button was retired when the context
await expect(dlBtn).toBeVisible(); // menu became the canonical action surface (SPA overhaul,
// commit 94b2e29). The picked-root folder doesn't render as
// a row (only its CONTENTS do), so we test the next-level
// folder: right-click sub/, download sub.zip.
const subRow = page.locator('.tree-row', { has: page.locator('.tree-name__label', { hasText: /^sub$/ }) });
await subRow.waitFor({ state: 'visible', timeout: 5000 });
const [download] = await Promise.all([ const [download] = await Promise.all([
page.waitForEvent('download'), page.waitForEvent('download'),
dlBtn.click(), (async () => {
await subRow.click({ button: 'right' });
await page.locator('.zddc-menu__item', { hasText: 'Download ZIP' })
.first()
.click();
})(),
]); ]);
expect(download.suggestedFilename()).toBe('mock-folder.zip'); expect(download.suggestedFilename()).toBe('sub.zip');
const file = await download.path(); const file = await download.path();
const buf = await fs.readFile(file); const buf = await fs.readFile(file);
@ -190,9 +202,8 @@ test.describe('Browse', () => {
return Object.keys(z.files).filter((n) => !z.files[n].dir).sort(); return Object.keys(z.files).filter((n) => !z.files[n].dir).sort();
}, b64); }, b64);
expect(entries).toEqual([ expect(entries).toEqual([
'mock-folder/a.txt', 'sub/b.txt',
'mock-folder/sub/b.txt', 'sub/deep/c.txt',
'mock-folder/sub/deep/c.txt',
]); ]);
}); });
}); });

View file

@ -45,15 +45,16 @@ for (const tool of tools) {
const match = html.match(/class="build-timestamp">(?:<span[^>]*>)?([^<]+?)(?:<\/span>)?</); const match = html.match(/class="build-timestamp">(?:<span[^>]*>)?([^<]+?)(?:<\/span>)?</);
expect(match, 'build-timestamp element must have text content').toBeTruthy(); expect(match, 'build-timestamp element must have text content').toBeTruthy();
const label = match[1]; const label = match[1];
// Plain dev builds and --release alpha|beta share one label // Plain dev builds, ./build beta, and the retired
// shape — full UTC timestamp + short source SHA (with // ./build alpha all share one label shape — full UTC
// optional -dirty marker on plain dev when the tree is // timestamp + short source SHA (with optional -dirty
// uncommitted): // marker when the tree is uncommitted):
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6" // "v0.0.21-dev · 2026-05-21 16:11:12 · 736f422"
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6-dirty" // "v0.0.21-dev · 2026-05-21 16:11:12 · 736f422-dirty"
// "v0.0.17-beta · 2026-05-13 15:29:05 · e7f6334" // "v0.0.17-beta · 2026-05-13 15:29:05 · e7f6334"
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6"
// Stable cuts emit a bare version: "v0.0.17" // Stable cuts emit a bare version: "v0.0.17"
const isChannel = /^v\d+\.\d+\.\d+-(?:alpha|beta) · 20\d\d-\d\d-\d\d \d\d:\d\d:\d\d · [0-9a-f]+(?:-dirty)?$/.test(label); const isChannel = /^v\d+\.\d+\.\d+-(?:alpha|beta|dev) · 20\d\d-\d\d-\d\d \d\d:\d\d:\d\d · [0-9a-f]+(?:-dirty)?$/.test(label);
const isVersion = /^v\d+\.\d+\.\d+$/.test(label); const isVersion = /^v\d+\.\d+\.\d+$/.test(label);
expect(isChannel || isVersion, expect(isChannel || isVersion,
`Expected channel or version label, got: "${label}"` `Expected channel or version label, got: "${label}"`

View file

@ -26,10 +26,13 @@ const FETCH_MOCK = `
}; };
`; `;
// Listing entries match the post-reshape FileInfo shape — the
// landing's loader filters on `is_dir: true` (projects are folders).
// Older fixtures omitted this and silently filtered to empty.
const SAMPLE_PROJECTS = [ const SAMPLE_PROJECTS = [
{ name: '176109', url: '/176109/', title: 'Greenfield Substation' }, { name: '176109/', is_dir: true, url: '/176109/', title: 'Greenfield Substation' },
{ name: '197072', url: '/197072/', title: 'Brownfield Tap' }, { name: '197072/', is_dir: true, url: '/197072/', title: 'Brownfield Tap' },
{ name: '210045', url: '/210045/', title: '' }, { name: '210045/', is_dir: true, url: '/210045/', title: '' },
]; ];
async function loadLandingWithProjects(page, projects) { async function loadLandingWithProjects(page, projects) {

View file

@ -836,6 +836,25 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
/* Inline action button appended to a toast by zddc.cap.handleForbidden
when an Elevate path is offered. Stops click propagation on its own
so clicking the button doesn't also dismiss the toast. */
.zddc-toast__action {
display: inline-block;
margin-left: 0.75rem;
padding: 0.25rem 0.75rem;
background: var(--accent, var(--text));
color: var(--bg);
border: none;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.zddc-toast__action:hover {
filter: brightness(1.1);
}
/* shared/elevation.css — admin-elevation toggle in the tool header. /* shared/elevation.css — admin-elevation toggle in the tool header.
Renders only for users with admin scope (handled by elevation.js; Renders only for users with admin scope (handled by elevation.js;
the placeholder is `.hidden` by default). When visible, sits left the placeholder is `.hidden` by default). When visible, sits left
@ -2563,7 +2582,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp">v0.0.19</span> <span class="build-timestamp">v0.0.21</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>
@ -10859,6 +10878,170 @@ window.app.modules.filtering = {
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})(); })();
// shared/cap.js — client-side capability helpers for permission gating.
//
// Three small helpers, exposed under window.zddc.cap, that wrap the
// server's verbs / /.profile/access?path / 403 missing_verb surface:
//
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
// /.profile/access?path=<urlpath> and
// memoises per-path for the session.
// Used by tools to gate top-of-page
// affordances (Publish, +Add row,
// +New folder) on PathVerbs.
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
// "rwcda"-subset) for the listed verb.
// Transition: falls back to
// node.writable for 'w' when verbs
// is absent, so the legacy field still
// drives gating on old listings.
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
// parses the JSON body for
// missing_verb and renders a toast.
// Offers "Elevate" when the path's
// /.profile/access?path= reports a
// path_can_elevate_grant covering the
// missing verb.
//
// Tools using this module must concat shared/cap.js AFTER shared/
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.cap) return;
var pathCache = new Map(); // path → AccessView (or null sentinel)
async function fetchAccess(path) {
// file:// pages have no server to fetch /.profile/access from;
// calling fetch() there logs a browser-level error before our
// catch even runs. Short-circuit so offline tools (browse on
// a picked folder, form opened from a file URL) silently
// degrade to "no path-scoped info, fall back to existing
// gating signals".
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
return null;
}
try {
var url = '/.profile/access';
if (path) url += '?path=' + encodeURIComponent(path);
var resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
// at(path) — fetch path-scoped access view, memoised per path
// within the page session. Cache is page-scoped: any elevation
// toggle forces a hard reload (see shared/elevation.js), which
// resets the cache so stale-after-elevation isn't a concern. Pass
// null/undefined for the global view (no ?path=).
async function at(path) {
var key = path || '';
if (pathCache.has(key)) return pathCache.get(key);
var view = await fetchAccess(path);
pathCache.set(key, view);
return view;
}
// has(node, verb) — check a per-entry verbs string for a single
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
// Transition shim: when node.verbs is absent, fall back to
// node.writable for 'w' so the legacy field keeps editor save
// buttons working on old listings — drop this fallback once every
// tool's loader sets node.verbs unconditionally.
function has(node, verb) {
if (!node) return false;
if (typeof node.verbs === 'string') {
return node.verbs.indexOf(verb) !== -1;
}
if (verb === 'w' && typeof node.writable === 'boolean') {
return node.writable;
}
return false;
}
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
var VERB_LABELS = {
r: 'read',
w: 'write',
c: 'create',
d: 'delete',
a: 'edit access rules'
};
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
// decide whether to offer an Elevate action. opts.context is an
// optional string prefix shown before the verb message ("Save",
// "Delete", etc.) — purely cosmetic.
//
// Best-effort: when the body isn't JSON or missing_verb is
// absent, falls back to a plain "Forbidden" toast. Returns the
// Promise so callers can await before chaining.
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
try {
var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
} catch (_e) { /* non-JSON body */ }
var prefix = opts.context ? (opts.context + ': ') : '';
var verbLabel = VERB_LABELS[missing] || missing || '';
var msg;
if (verbLabel) {
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
} else {
msg = prefix + 'Forbidden.';
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
// action appended to the toast message; clicking sets the
// elevation cookie and reloads, matching the header toggle.
var canOffer = false;
if (opts.path && missing) {
var view = await at(opts.path);
if (view && typeof view.path_can_elevate_grant === 'string'
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
canOffer = true;
}
}
var toastFn = (window.zddc && window.zddc.toast) || function () {};
var el = toastFn(msg, 'error', { durationMs: 8000 });
if (canOffer && el && el.appendChild) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'zddc-toast__action';
btn.textContent = 'Elevate';
btn.addEventListener('click', function (ev) {
ev.stopPropagation(); // don't dismiss the toast
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
window.zddc.elevation.setElevated(true);
window.location.reload();
}
});
el.appendChild(btn);
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})();
</script> </script>
</body> </body>
</html> </html>

View file

@ -836,6 +836,25 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
/* Inline action button appended to a toast by zddc.cap.handleForbidden
when an Elevate path is offered. Stops click propagation on its own
so clicking the button doesn't also dismiss the toast. */
.zddc-toast__action {
display: inline-block;
margin-left: 0.75rem;
padding: 0.25rem 0.75rem;
background: var(--accent, var(--text));
color: var(--bg);
border: none;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.zddc-toast__action:hover {
filter: brightness(1.1);
}
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor /* shared/logo.css — paired with shared/logo.js. The wrapping anchor
inherits the logo's box and adds a subtle hover/focus affordance inherits the logo's box and adds a subtle hover/focus affordance
so it reads as clickable without altering the logo's visual weight. */ so it reads as clickable without altering the logo's visual weight. */
@ -2350,7 +2369,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp">v0.0.19</span> <span class="build-timestamp">v0.0.21</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
@ -5356,8 +5375,11 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// //
// `items` is an array (or a function returning an array, evaluated // `items` is an array (or a function returning an array, evaluated
// against `context` at open-time). Each entry is one of: // against `context` at open-time). Each entry is one of:
// { label, action, icon?, accel?, disabled?, visible?, danger? } // { label, action, icon?, accel?, disabled?, visible?, danger?, tooltip? }
// — a normal menu item; `action(ctx)` fires on click/Enter. // — a normal menu item; `action(ctx)` fires on click/Enter.
// `tooltip` (string or fn(ctx)) sets the row's title attribute —
// useful for explaining WHY a disabled item is unavailable
// ("You don't have write access here", etc.).
// { label, checked, action, ... } // { label, checked, action, ... }
// — toggle item; `checked` may be a bool or a fn(ctx). Renders // — toggle item; `checked` may be a bool or a fn(ctx). Renders
// a ✓ in the gutter when truthy. // a ✓ in the gutter when truthy.
@ -5368,10 +5390,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// are collapsed automatically so callers can build items // are collapsed automatically so callers can build items
// conditionally without managing dividers. // conditionally without managing dividers.
// //
// Any of `label`, `checked`, `visible`, `disabled`, and `items` may // Any of `label`, `checked`, `visible`, `disabled`, `tooltip`, and
// be a function — each is invoked with the context object so callers // `items` may be a function — each is invoked with the context object
// can render fully context-aware menus from a single declarative // so callers can render fully context-aware menus from a single
// config. // declarative config.
// //
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a // Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
// submenu, ArrowLeft / Escape backs up one level (or closes if // submenu, ArrowLeft / Escape backs up one level (or closes if
@ -5493,6 +5515,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
row.classList.add('is-disabled'); row.classList.add('is-disabled');
row.setAttribute('aria-disabled', 'true'); row.setAttribute('aria-disabled', 'true');
} }
if ('tooltip' in item) {
var tip = resolve(item.tooltip, ctx);
if (tip) row.title = String(tip);
}
row.setAttribute('role', row.setAttribute('role',
hasSub ? 'menuitem' hasSub ? 'menuitem'
: (isToggle ? 'menuitemcheckbox' : 'menuitem')); : (isToggle ? 'menuitemcheckbox' : 'menuitem'));
@ -5876,6 +5902,170 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})(); })();
// shared/cap.js — client-side capability helpers for permission gating.
//
// Three small helpers, exposed under window.zddc.cap, that wrap the
// server's verbs / /.profile/access?path / 403 missing_verb surface:
//
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
// /.profile/access?path=<urlpath> and
// memoises per-path for the session.
// Used by tools to gate top-of-page
// affordances (Publish, +Add row,
// +New folder) on PathVerbs.
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
// "rwcda"-subset) for the listed verb.
// Transition: falls back to
// node.writable for 'w' when verbs
// is absent, so the legacy field still
// drives gating on old listings.
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
// parses the JSON body for
// missing_verb and renders a toast.
// Offers "Elevate" when the path's
// /.profile/access?path= reports a
// path_can_elevate_grant covering the
// missing verb.
//
// Tools using this module must concat shared/cap.js AFTER shared/
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.cap) return;
var pathCache = new Map(); // path → AccessView (or null sentinel)
async function fetchAccess(path) {
// file:// pages have no server to fetch /.profile/access from;
// calling fetch() there logs a browser-level error before our
// catch even runs. Short-circuit so offline tools (browse on
// a picked folder, form opened from a file URL) silently
// degrade to "no path-scoped info, fall back to existing
// gating signals".
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
return null;
}
try {
var url = '/.profile/access';
if (path) url += '?path=' + encodeURIComponent(path);
var resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
// at(path) — fetch path-scoped access view, memoised per path
// within the page session. Cache is page-scoped: any elevation
// toggle forces a hard reload (see shared/elevation.js), which
// resets the cache so stale-after-elevation isn't a concern. Pass
// null/undefined for the global view (no ?path=).
async function at(path) {
var key = path || '';
if (pathCache.has(key)) return pathCache.get(key);
var view = await fetchAccess(path);
pathCache.set(key, view);
return view;
}
// has(node, verb) — check a per-entry verbs string for a single
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
// Transition shim: when node.verbs is absent, fall back to
// node.writable for 'w' so the legacy field keeps editor save
// buttons working on old listings — drop this fallback once every
// tool's loader sets node.verbs unconditionally.
function has(node, verb) {
if (!node) return false;
if (typeof node.verbs === 'string') {
return node.verbs.indexOf(verb) !== -1;
}
if (verb === 'w' && typeof node.writable === 'boolean') {
return node.writable;
}
return false;
}
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
var VERB_LABELS = {
r: 'read',
w: 'write',
c: 'create',
d: 'delete',
a: 'edit access rules'
};
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
// decide whether to offer an Elevate action. opts.context is an
// optional string prefix shown before the verb message ("Save",
// "Delete", etc.) — purely cosmetic.
//
// Best-effort: when the body isn't JSON or missing_verb is
// absent, falls back to a plain "Forbidden" toast. Returns the
// Promise so callers can await before chaining.
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
try {
var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
} catch (_e) { /* non-JSON body */ }
var prefix = opts.context ? (opts.context + ': ') : '';
var verbLabel = VERB_LABELS[missing] || missing || '';
var msg;
if (verbLabel) {
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
} else {
msg = prefix + 'Forbidden.';
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
// action appended to the toast message; clicking sets the
// elevation cookie and reloads, matching the header toggle.
var canOffer = false;
if (opts.path && missing) {
var view = await at(opts.path);
if (view && typeof view.path_can_elevate_grant === 'string'
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
canOffer = true;
}
}
var toastFn = (window.zddc && window.zddc.toast) || function () {};
var el = toastFn(msg, 'error', { durationMs: 8000 });
if (canOffer && el && el.appendChild) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'zddc-toast__action';
btn.textContent = 'Elevate';
btn.addEventListener('click', function (ev) {
ev.stopPropagation(); // don't dismiss the toast
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
window.zddc.elevation.setElevated(true);
window.location.reload();
}
});
el.appendChild(btn);
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})();
// shared/icons.js — minimal outline SVG sprite for ZDDC tools. // shared/icons.js — minimal outline SVG sprite for ZDDC tools.
// //
// Vendored from Lucide (https://lucide.dev, ISC). Only the 16 // Vendored from Lucide (https://lucide.dev, ISC). Only the 16
@ -6607,8 +6797,26 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// Server-computed write authority — true if the policy // Server-computed write authority — true if the policy
// decider would allow a PUT for the calling principal. // decider would allow a PUT for the calling principal.
// Absent / false means "save will 403"; preview editors // Absent / false means "save will 403"; preview editors
// read this to mount in read-only mode. // read this to mount in read-only mode. Superseded by
// verbs (below); kept in lockstep during the transition.
writable: !!e.writable, writable: !!e.writable,
// Server-computed verb set: canonical "rwcda" subset the
// calling principal holds at this entry's URL. Per-entry
// gating in the context menu (Rename/Delete) reads this
// through zddc.cap.has(node, 'w'|'d').
//
// "rw…" — zddc-server emitted explicit grant.
// "" — zddc-server emitted explicit zero grant
// (rare; usually the entry would have been
// filtered before reaching the client).
// undefined — the server didn't emit a verbs field at
// all (Caddy or any non-zddc backend).
// cap.has and the events.js gates treat
// this as "verbs unknown" and skip the
// per-entry cascade gate; canMutate +
// whatever the server enforces on the
// actual PUT/DELETE still apply.
verbs: typeof e.verbs === 'string' ? e.verbs : undefined,
// FS-API specific (null in server mode): // FS-API specific (null in server mode):
handle: null handle: null
}; };
@ -6821,7 +7029,16 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// whether to mount read-only. Dropping the field here // whether to mount read-only. Dropping the field here
// silently makes every node read-only — the actual root // silently makes every node read-only — the actual root
// cause behind "I'm admin but the editor says read-only". // cause behind "I'm admin but the editor says read-only".
writable: !!raw.writable writable: !!raw.writable,
// Server-computed verb set (canonical "rwcda" subset).
// Per-entry permission gating reads this via
// zddc.cap.has(node, verb). Three states:
// "rw…" — zddc-server explicit grant
// "" — zddc-server explicit zero grant
// 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
}; };
state.nodes.set(id, node); state.nodes.set(id, node);
return node; return node;
@ -8125,11 +8342,14 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
function canSave(node) { function canSave(node) {
if (isZipMemberNode(node)) return false; if (isZipMemberNode(node)) return false;
// Server-computed authority gate. The listing's `writable` // Server-computed authority gate. The listing's verbs string
// bit reflects what a PUT would do — false here means the // tells us whether a PUT to this entry would be allowed —
// file API would 403 the save, so we mount in read-only // false here means the file API would 403, so we mount in
// mode rather than letting the user type and lose changes. // read-only mode rather than letting the user type and lose
if (node.url && window.app.state.source === 'server' && !node.writable) return false; // changes. cap.has() falls back to node.writable for 'w'
// when verbs is absent (offline FS-API listings).
if (node.url && window.app.state.source === 'server'
&& window.zddc.cap && !window.zddc.cap.has(node, 'w')) return false;
if (node.handle && typeof node.handle.createWritable === 'function') return true; if (node.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true; if (node.url && window.app.state.source === 'server') return true;
return false; return false;
@ -8665,10 +8885,15 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// user home, canonical-folder virtuals) is just a tree // user home, canonical-folder virtuals) is just a tree
// affordance, not a writable file. // affordance, not a writable file.
if (node.virtual && node.name !== '.zddc') return false; if (node.virtual && node.name !== '.zddc') return false;
// Server-computed authority gate. Mirrors the markdown editor's // Server-computed authority gate. The virtual .zddc entry
// check — listing's `writable` bit is the same decision the // requires the admin verb 'a' (matches fileapi.go's
// file API would reach on PUT. // ActionAdmin gate at the .zddc URL); regular YAML files
if (node.url && window.app.state.source === 'server' && !node.writable) return false; // require write 'w'. cap.has falls back to node.writable for
// 'w' when verbs is absent (offline FS-API listings).
if (node.url && window.app.state.source === 'server' && window.zddc.cap) {
var needed = node.name === '.zddc' ? 'a' : 'w';
if (!window.zddc.cap.has(node, needed)) return false;
}
if (node.handle && typeof node.handle.createWritable === 'function') return true; if (node.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true; if (node.url && window.app.state.source === 'server') return true;
return false; return false;
@ -10834,13 +11059,22 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// stage.js — Stage and Unstage workflow modals. // stage.js — Stage and Unstage workflow modals.
// //
// Stage: move a file from working/<…>/ into a transmittal folder under // After the layout reshape, working/ and staging/ live INSIDE each
// staging/<…>/. Modal lists existing transmittal folders in staging/ // party folder: archive/<party>/working/<email>/<file> and
// plus a "New transmittal folder…" option that prompts for a ZDDC- // archive/<party>/staging/<batch>/<file>. Stage and Unstage are now
// conforming name and mkdirs it before the move. // per-party — the destination batch is always inside the SAME
// party's staging slot. The party context is read from the source
// file's path.
// //
// Unstage: move a file from staging/<transmittal>/ back to the user's // Stage: move a file from archive/<party>/working/<…> into a
// working/<email>/ home (overridable). // transmittal folder under archive/<party>/staging/<…>. Modal lists
// existing transmittal folders in the party's staging/ plus a "New
// transmittal folder…" option that prompts for a ZDDC-conforming
// name and mkdirs it before the move.
//
// Unstage: move a file from archive/<party>/staging/<transmittal>/
// back to the user's archive/<party>/working/<email>/ home
// (overridable).
// //
// Both reuse the existing X-ZDDC-Op: move primitive — no new composite // Both reuse the existing X-ZDDC-Op: move primitive — no new composite
// endpoint is needed; the client just orchestrates one POST per file // endpoint is needed; the client just orchestrates one POST per file
@ -10860,32 +11094,37 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
} }
// ── Scope detection: path-shape, not cascade-content ────────────── // ── Scope detection: path-shape, not cascade-content ──────────────
// A file is stageable if its containing folder lives under // A file is stageable if its path matches
// /<project>/working/<…>. Unstageable if it lives under // /<project>/archive/<party>/working/<…>. Unstageable if it
// /<project>/staging/<transmittal>/<…>. Both are path-shape // matches /<project>/archive/<party>/staging/<transmittal>/<…>.
// queries — content/ACL is enforced server-side. // Both are path-shape queries — content/ACL is enforced server-
// side.
function projectAndSubtree(path) { // projectPartySlot returns { project, party, slot, rest } when
// path matches /<project>/archive/<party>/<slot>/<rest>, or
// null on non-match.
function projectPartySlot(path) {
var rel = path.replace(/^\/+|\/+$/g, '').split('/'); var rel = path.replace(/^\/+|\/+$/g, '').split('/');
if (rel.length < 2) return null; if (rel.length < 4) return null;
return { project: rel[0], subtree: rel[1], rest: rel.slice(2) }; if (rel[1].toLowerCase() !== 'archive') return null;
return { project: rel[0], party: rel[2], slot: rel[3], rest: rel.slice(4) };
} }
function isStageableFile(node) { function isStageableFile(node) {
if (!node || node.isDir || node.virtual) return false; if (!node || node.isDir || node.virtual) return false;
var tree = window.app.modules.tree; var tree = window.app.modules.tree;
if (!tree) return false; if (!tree) return false;
var p = projectAndSubtree(tree.pathFor(node)); var p = projectPartySlot(tree.pathFor(node));
return !!(p && p.subtree === 'working' && p.rest.length >= 1); return !!(p && p.slot === 'working' && p.rest.length >= 1);
} }
function isUnstageableFile(node) { function isUnstageableFile(node) {
if (!node || node.isDir || node.virtual) return false; if (!node || node.isDir || node.virtual) return false;
var tree = window.app.modules.tree; var tree = window.app.modules.tree;
if (!tree) return false; if (!tree) return false;
var p = projectAndSubtree(tree.pathFor(node)); var p = projectPartySlot(tree.pathFor(node));
// staging/<transmittal-folder>/<file> — at least one folder // archive/<party>/staging/<transmittal-folder>/<file> — at
// segment between staging/ and the file. // least one folder segment between staging/ and the file.
return !!(p && p.subtree === 'staging' && p.rest.length >= 2); return !!(p && p.slot === 'staging' && p.rest.length >= 2);
} }
// ── Server helpers ───────────────────────────────────────────────── // ── Server helpers ─────────────────────────────────────────────────
@ -10903,8 +11142,9 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
} }
async function fetchStagingFolders(project) { async function fetchStagingFolders(project, party) {
var entries = await listDir('/' + project + '/staging/'); var entries = await listDir(
'/' + project + '/archive/' + encodeURIComponent(party) + '/staging/');
return entries return entries
.filter(function (e) { return e && e.isDir; }) .filter(function (e) { return e && e.isDir; })
.map(function (e) { return e.name; }); .map(function (e) { return e.name; });
@ -11090,14 +11330,15 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
var tree = window.app.modules.tree; var tree = window.app.modules.tree;
if (!tree) return; if (!tree) return;
var srcUrl = tree.pathFor(node); var srcUrl = tree.pathFor(node);
var info = projectAndSubtree(srcUrl); var info = projectPartySlot(srcUrl);
if (!info || info.subtree !== 'working') { if (!info || info.slot !== 'working') {
status('Stage applies only to files under working/.', 'error'); status('Stage applies only to files under archive/<party>/working/.', 'error');
return; return;
} }
var stagingBase = '/' + info.project + '/staging/'; var stagingBase = '/' + info.project + '/archive/' +
encodeURIComponent(info.party) + '/staging/';
var folders; var folders;
try { folders = await fetchStagingFolders(info.project); } try { folders = await fetchStagingFolders(info.project, info.party); }
catch (e) { catch (e) {
status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error'); status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error');
return; return;
@ -11124,20 +11365,21 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
status((e && e.message) || 'move failed', 'error'); status((e && e.message) || 'move failed', 'error');
return; return;
} }
status('Staged ' + node.name + ' → staging/' + choice.folderName + '/ — reload to see the move.', 'success'); status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/ — reload to see the move.', 'success');
} }
async function invokeUnstage(node) { async function invokeUnstage(node) {
var tree = window.app.modules.tree; var tree = window.app.modules.tree;
if (!tree) return; if (!tree) return;
var srcUrl = tree.pathFor(node); var srcUrl = tree.pathFor(node);
var info = projectAndSubtree(srcUrl); var info = projectPartySlot(srcUrl);
if (!info || info.subtree !== 'staging') { if (!info || info.slot !== 'staging') {
status('Unstage applies only to files under staging/.', 'error'); status('Unstage applies only to files under archive/<party>/staging/.', 'error');
return; return;
} }
var email = await fetchSelfEmail(); var email = await fetchSelfEmail();
var defaultTarget = '/' + info.project + '/working/' + (email || '') + '/'; var defaultTarget = '/' + info.project + '/archive/' +
encodeURIComponent(info.party) + '/working/' + (email || '') + '/';
var choice; var choice;
try { try {
choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget }); choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget });
@ -12206,16 +12448,55 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
{ separator: true }, { separator: true },
// ── Rename + Delete (the permission-gated pair) ── // ── Rename + Delete (the permission-gated pair) ──
//
// Two gates compose: canMutate() rules out un-writable
// sources (offline FS-API without a handle, zip members,
// virtual placeholders) and — when the listing carries
// server-cascade verbs — zddc.cap.has(node, verb) applies
// the per-entry ACL. The verbs gate is server-mode only;
// file:// FS-API and plain Caddy listings have no verbs
// field, so we fall back to canMutate alone (FS-API
// enforces locally; Caddy has no PUT/DELETE either way).
// Server-side ACL still has the final say on the actual
// PUT/DELETE if a stale client tries the action.
{ {
label: 'Rename…', label: 'Rename…',
disabled: function (c) { return !canMutate(c); }, disabled: function (c) {
if (!canMutate(c)) return true;
if (!serverMode || !window.zddc.cap) return false;
// verbs===undefined → Caddy or other non-zddc
// server, no cascade signal to gate on. verbs===""
// is zddc-server's explicit zero grant; still
// gate (disable). verbs==="rw…" → check the bit.
if (typeof c.node.verbs !== 'string') return false;
return !window.zddc.cap.has(c.node, 'w');
},
tooltip: function (c) {
if (!serverMode || !canMutate(c)) return '';
if (!window.zddc.cap) return '';
if (typeof c.node.verbs !== 'string') return '';
if (window.zddc.cap.has(c.node, 'w')) return '';
return "You don't have write access to this item.";
},
action: function (c) { renameNode(c.node); } action: function (c) { renameNode(c.node); }
}, },
{ {
label: 'Delete…', label: 'Delete…',
icon: '🗑', icon: '🗑',
danger: true, danger: true,
disabled: function (c) { return !canMutate(c); }, disabled: function (c) {
if (!canMutate(c)) return true;
if (!serverMode || !window.zddc.cap) return false;
if (typeof c.node.verbs !== 'string') return false;
return !window.zddc.cap.has(c.node, 'd');
},
tooltip: function (c) {
if (!serverMode || !canMutate(c)) return '';
if (!window.zddc.cap) return '';
if (typeof c.node.verbs !== 'string') return '';
if (window.zddc.cap.has(c.node, 'd')) return '';
return "You don't have delete access to this item.";
},
action: function (c) { deleteNode(c.node); } action: function (c) { deleteNode(c.node); }
}, },
{ separator: true }, { separator: true },

View file

@ -836,6 +836,25 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
/* Inline action button appended to a toast by zddc.cap.handleForbidden
when an Elevate path is offered. Stops click propagation on its own
so clicking the button doesn't also dismiss the toast. */
.zddc-toast__action {
display: inline-block;
margin-left: 0.75rem;
padding: 0.25rem 0.75rem;
background: var(--accent, var(--text));
color: var(--bg);
border: none;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.zddc-toast__action:hover {
filter: brightness(1.1);
}
/* shared/elevation.css — admin-elevation toggle in the tool header. /* shared/elevation.css — admin-elevation toggle in the tool header.
Renders only for users with admin scope (handled by elevation.js; Renders only for users with admin scope (handled by elevation.js;
the placeholder is `.hidden` by default). When visible, sits left the placeholder is `.hidden` by default). When visible, sits left
@ -1774,7 +1793,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp">v0.0.19</span> <span class="build-timestamp">v0.0.21</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <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> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
@ -9994,6 +10013,170 @@ X.B(E,Y);return E}return J}())
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})(); })();
// shared/cap.js — client-side capability helpers for permission gating.
//
// Three small helpers, exposed under window.zddc.cap, that wrap the
// server's verbs / /.profile/access?path / 403 missing_verb surface:
//
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
// /.profile/access?path=<urlpath> and
// memoises per-path for the session.
// Used by tools to gate top-of-page
// affordances (Publish, +Add row,
// +New folder) on PathVerbs.
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
// "rwcda"-subset) for the listed verb.
// Transition: falls back to
// node.writable for 'w' when verbs
// is absent, so the legacy field still
// drives gating on old listings.
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
// parses the JSON body for
// missing_verb and renders a toast.
// Offers "Elevate" when the path's
// /.profile/access?path= reports a
// path_can_elevate_grant covering the
// missing verb.
//
// Tools using this module must concat shared/cap.js AFTER shared/
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.cap) return;
var pathCache = new Map(); // path → AccessView (or null sentinel)
async function fetchAccess(path) {
// file:// pages have no server to fetch /.profile/access from;
// calling fetch() there logs a browser-level error before our
// catch even runs. Short-circuit so offline tools (browse on
// a picked folder, form opened from a file URL) silently
// degrade to "no path-scoped info, fall back to existing
// gating signals".
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
return null;
}
try {
var url = '/.profile/access';
if (path) url += '?path=' + encodeURIComponent(path);
var resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
// at(path) — fetch path-scoped access view, memoised per path
// within the page session. Cache is page-scoped: any elevation
// toggle forces a hard reload (see shared/elevation.js), which
// resets the cache so stale-after-elevation isn't a concern. Pass
// null/undefined for the global view (no ?path=).
async function at(path) {
var key = path || '';
if (pathCache.has(key)) return pathCache.get(key);
var view = await fetchAccess(path);
pathCache.set(key, view);
return view;
}
// has(node, verb) — check a per-entry verbs string for a single
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
// Transition shim: when node.verbs is absent, fall back to
// node.writable for 'w' so the legacy field keeps editor save
// buttons working on old listings — drop this fallback once every
// tool's loader sets node.verbs unconditionally.
function has(node, verb) {
if (!node) return false;
if (typeof node.verbs === 'string') {
return node.verbs.indexOf(verb) !== -1;
}
if (verb === 'w' && typeof node.writable === 'boolean') {
return node.writable;
}
return false;
}
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
var VERB_LABELS = {
r: 'read',
w: 'write',
c: 'create',
d: 'delete',
a: 'edit access rules'
};
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
// decide whether to offer an Elevate action. opts.context is an
// optional string prefix shown before the verb message ("Save",
// "Delete", etc.) — purely cosmetic.
//
// Best-effort: when the body isn't JSON or missing_verb is
// absent, falls back to a plain "Forbidden" toast. Returns the
// Promise so callers can await before chaining.
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
try {
var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
} catch (_e) { /* non-JSON body */ }
var prefix = opts.context ? (opts.context + ': ') : '';
var verbLabel = VERB_LABELS[missing] || missing || '';
var msg;
if (verbLabel) {
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
} else {
msg = prefix + 'Forbidden.';
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
// action appended to the toast message; clicking sets the
// elevation cookie and reloads, matching the header toggle.
var canOffer = false;
if (opts.path && missing) {
var view = await at(opts.path);
if (view && typeof view.path_can_elevate_grant === 'string'
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
canOffer = true;
}
}
var toastFn = (window.zddc && window.zddc.toast) || function () {};
var el = toastFn(msg, 'error', { durationMs: 8000 });
if (canOffer && el && el.appendChild) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'zddc-toast__action';
btn.textContent = 'Elevate';
btn.addEventListener('click', function (ev) {
ev.stopPropagation(); // don't dismiss the toast
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
window.zddc.elevation.setElevated(true);
window.location.reload();
}
});
el.appendChild(btn);
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})();
</script> </script>
</body> </body>
</html> </html>

View file

@ -836,6 +836,25 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
/* Inline action button appended to a toast by zddc.cap.handleForbidden
when an Elevate path is offered. Stops click propagation on its own
so clicking the button doesn't also dismiss the toast. */
.zddc-toast__action {
display: inline-block;
margin-left: 0.75rem;
padding: 0.25rem 0.75rem;
background: var(--accent, var(--text));
color: var(--bg);
border: none;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.zddc-toast__action:hover {
filter: brightness(1.1);
}
/* shared/elevation.css — admin-elevation toggle in the tool header. /* shared/elevation.css — admin-elevation toggle in the tool header.
Renders only for users with admin scope (handled by elevation.js; Renders only for users with admin scope (handled by elevation.js;
the placeholder is `.hidden` by default). When visible, sits left the placeholder is `.hidden` by default). When visible, sits left
@ -1517,7 +1536,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp">v0.0.19</span> <span class="build-timestamp">v0.0.21</span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -2676,6 +2695,170 @@ body {
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})(); })();
// shared/cap.js — client-side capability helpers for permission gating.
//
// Three small helpers, exposed under window.zddc.cap, that wrap the
// server's verbs / /.profile/access?path / 403 missing_verb surface:
//
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
// /.profile/access?path=<urlpath> and
// memoises per-path for the session.
// Used by tools to gate top-of-page
// affordances (Publish, +Add row,
// +New folder) on PathVerbs.
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
// "rwcda"-subset) for the listed verb.
// Transition: falls back to
// node.writable for 'w' when verbs
// is absent, so the legacy field still
// drives gating on old listings.
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
// parses the JSON body for
// missing_verb and renders a toast.
// Offers "Elevate" when the path's
// /.profile/access?path= reports a
// path_can_elevate_grant covering the
// missing verb.
//
// Tools using this module must concat shared/cap.js AFTER shared/
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.cap) return;
var pathCache = new Map(); // path → AccessView (or null sentinel)
async function fetchAccess(path) {
// file:// pages have no server to fetch /.profile/access from;
// calling fetch() there logs a browser-level error before our
// catch even runs. Short-circuit so offline tools (browse on
// a picked folder, form opened from a file URL) silently
// degrade to "no path-scoped info, fall back to existing
// gating signals".
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
return null;
}
try {
var url = '/.profile/access';
if (path) url += '?path=' + encodeURIComponent(path);
var resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
// at(path) — fetch path-scoped access view, memoised per path
// within the page session. Cache is page-scoped: any elevation
// toggle forces a hard reload (see shared/elevation.js), which
// resets the cache so stale-after-elevation isn't a concern. Pass
// null/undefined for the global view (no ?path=).
async function at(path) {
var key = path || '';
if (pathCache.has(key)) return pathCache.get(key);
var view = await fetchAccess(path);
pathCache.set(key, view);
return view;
}
// has(node, verb) — check a per-entry verbs string for a single
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
// Transition shim: when node.verbs is absent, fall back to
// node.writable for 'w' so the legacy field keeps editor save
// buttons working on old listings — drop this fallback once every
// tool's loader sets node.verbs unconditionally.
function has(node, verb) {
if (!node) return false;
if (typeof node.verbs === 'string') {
return node.verbs.indexOf(verb) !== -1;
}
if (verb === 'w' && typeof node.writable === 'boolean') {
return node.writable;
}
return false;
}
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
var VERB_LABELS = {
r: 'read',
w: 'write',
c: 'create',
d: 'delete',
a: 'edit access rules'
};
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
// decide whether to offer an Elevate action. opts.context is an
// optional string prefix shown before the verb message ("Save",
// "Delete", etc.) — purely cosmetic.
//
// Best-effort: when the body isn't JSON or missing_verb is
// absent, falls back to a plain "Forbidden" toast. Returns the
// Promise so callers can await before chaining.
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
try {
var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
} catch (_e) { /* non-JSON body */ }
var prefix = opts.context ? (opts.context + ': ') : '';
var verbLabel = VERB_LABELS[missing] || missing || '';
var msg;
if (verbLabel) {
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
} else {
msg = prefix + 'Forbidden.';
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
// action appended to the toast message; clicking sets the
// elevation cookie and reloads, matching the header toggle.
var canOffer = false;
if (opts.path && missing) {
var view = await at(opts.path);
if (view && typeof view.path_can_elevate_grant === 'string'
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
canOffer = true;
}
}
var toastFn = (window.zddc && window.zddc.toast) || function () {};
var el = toastFn(msg, 'error', { durationMs: 8000 });
if (canOffer && el && el.appendChild) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'zddc-toast__action';
btn.textContent = 'Elevate';
btn.addEventListener('click', function (ev) {
ev.stopPropagation(); // don't dismiss the toast
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
window.zddc.elevation.setElevated(true);
window.location.reload();
}
});
el.appendChild(btn);
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})();
(function() { (function() {
'use strict'; 'use strict';
// ZDDC landing page — project picker. // ZDDC landing page — project picker.

View file

@ -840,6 +840,25 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
/* Inline action button appended to a toast by zddc.cap.handleForbidden
when an Elevate path is offered. Stops click propagation on its own
so clicking the button doesn't also dismiss the toast. */
.zddc-toast__action {
display: inline-block;
margin-left: 0.75rem;
padding: 0.25rem 0.75rem;
background: var(--accent, var(--text));
color: var(--bg);
border: none;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.zddc-toast__action:hover {
filter: brightness(1.1);
}
/* shared/elevation.css — admin-elevation toggle in the tool header. /* shared/elevation.css — admin-elevation toggle in the tool header.
Renders only for users with admin scope (handled by elevation.js; Renders only for users with admin scope (handled by elevation.js;
the placeholder is `.hidden` by default). When visible, sits left the placeholder is `.hidden` by default). When visible, sits left
@ -2616,7 +2635,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp">v0.0.19</span> <span class="build-timestamp">v0.0.21</span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;
@ -13418,6 +13437,170 @@ X.B(E,Y);return E}return J}())
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})(); })();
// shared/cap.js — client-side capability helpers for permission gating.
//
// Three small helpers, exposed under window.zddc.cap, that wrap the
// server's verbs / /.profile/access?path / 403 missing_verb surface:
//
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
// /.profile/access?path=<urlpath> and
// memoises per-path for the session.
// Used by tools to gate top-of-page
// affordances (Publish, +Add row,
// +New folder) on PathVerbs.
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
// "rwcda"-subset) for the listed verb.
// Transition: falls back to
// node.writable for 'w' when verbs
// is absent, so the legacy field still
// drives gating on old listings.
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
// parses the JSON body for
// missing_verb and renders a toast.
// Offers "Elevate" when the path's
// /.profile/access?path= reports a
// path_can_elevate_grant covering the
// missing verb.
//
// Tools using this module must concat shared/cap.js AFTER shared/
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.cap) return;
var pathCache = new Map(); // path → AccessView (or null sentinel)
async function fetchAccess(path) {
// file:// pages have no server to fetch /.profile/access from;
// calling fetch() there logs a browser-level error before our
// catch even runs. Short-circuit so offline tools (browse on
// a picked folder, form opened from a file URL) silently
// degrade to "no path-scoped info, fall back to existing
// gating signals".
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
return null;
}
try {
var url = '/.profile/access';
if (path) url += '?path=' + encodeURIComponent(path);
var resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
// at(path) — fetch path-scoped access view, memoised per path
// within the page session. Cache is page-scoped: any elevation
// toggle forces a hard reload (see shared/elevation.js), which
// resets the cache so stale-after-elevation isn't a concern. Pass
// null/undefined for the global view (no ?path=).
async function at(path) {
var key = path || '';
if (pathCache.has(key)) return pathCache.get(key);
var view = await fetchAccess(path);
pathCache.set(key, view);
return view;
}
// has(node, verb) — check a per-entry verbs string for a single
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
// Transition shim: when node.verbs is absent, fall back to
// node.writable for 'w' so the legacy field keeps editor save
// buttons working on old listings — drop this fallback once every
// tool's loader sets node.verbs unconditionally.
function has(node, verb) {
if (!node) return false;
if (typeof node.verbs === 'string') {
return node.verbs.indexOf(verb) !== -1;
}
if (verb === 'w' && typeof node.writable === 'boolean') {
return node.writable;
}
return false;
}
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
var VERB_LABELS = {
r: 'read',
w: 'write',
c: 'create',
d: 'delete',
a: 'edit access rules'
};
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
// decide whether to offer an Elevate action. opts.context is an
// optional string prefix shown before the verb message ("Save",
// "Delete", etc.) — purely cosmetic.
//
// Best-effort: when the body isn't JSON or missing_verb is
// absent, falls back to a plain "Forbidden" toast. Returns the
// Promise so callers can await before chaining.
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
try {
var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
} catch (_e) { /* non-JSON body */ }
var prefix = opts.context ? (opts.context + ': ') : '';
var verbLabel = VERB_LABELS[missing] || missing || '';
var msg;
if (verbLabel) {
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
} else {
msg = prefix + 'Forbidden.';
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
// action appended to the toast message; clicking sets the
// elevation cookie and reloads, matching the header toggle.
var canOffer = false;
if (opts.path && missing) {
var view = await at(opts.path);
if (view && typeof view.path_can_elevate_grant === 'string'
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
canOffer = true;
}
}
var toastFn = (window.zddc && window.zddc.toast) || function () {};
var el = toastFn(msg, 'error', { durationMs: 8000 });
if (canOffer && el && el.appendChild) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'zddc-toast__action';
btn.textContent = 'Elevate';
btn.addEventListener('click', function (ev) {
ev.stopPropagation(); // don't dismiss the toast
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
window.zddc.elevation.setElevated(true);
window.location.reload();
}
});
el.appendChild(btn);
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})();
(function (app) { (function (app) {
'use strict'; 'use strict';

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.19 archive=v0.0.21
transmittal=v0.0.19 transmittal=v0.0.21
classifier=v0.0.19 classifier=v0.0.21
landing=v0.0.19 landing=v0.0.21
form=v0.0.19 form=v0.0.21
tables=v0.0.19 tables=v0.0.21
browse=v0.0.19 browse=v0.0.21

View file

@ -758,11 +758,12 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// unfenced so ancestor grants still cascade through. // unfenced so ancestor grants still cascade through.
if email != "" { if email != "" {
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) { if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
roles := zddc.AutoOwnRolesAt(cfg.Root, abs)
var werr error var werr error
if zddc.AutoOwnFencedAt(cfg.Root, abs) { if zddc.AutoOwnFencedAt(cfg.Root, abs) {
werr = zddc.WriteAutoOwnZddcFenced(abs, email) werr = zddc.WriteAutoOwnZddcFenced(abs, email, roles)
} else { } else {
werr = zddc.WriteAutoOwnZddc(abs, email) werr = zddc.WriteAutoOwnZddc(abs, email, roles)
} }
if werr != nil { if werr != nil {
slog.Warn("auto-own .zddc write failed", "path", abs, "err", werr) slog.Warn("auto-own .zddc write failed", "path", abs, "err", werr)

View file

@ -23,11 +23,16 @@ func planReviewSetup(t *testing.T) (config.Config, func(target, email string, bo
t.Helper() t.Helper()
root := t.TempDir() root := t.TempDir()
// Root .zddc grants alice subtree-admin everywhere AND sets the // Root .zddc grants alice root-admin AND adds her to the
// document_controller role so the cascade's reviewing/+staging/ // document_controller role. The root-admin status + elevated
// admin grants resolve to her. The role membership also confers // principal (set on the request below) is what carries her past
// `c` authority on received/ via the WORM list in the defaults, // Plan Review's ActionAdmin checks — DCs are no longer subtree-
// which Plan Review's pre-flight requires. // admin by default; their party-level `a` verb comes from the
// auto-own .zddc that ensure.go writes when they mkdir
// archive/<party>/ (carrying auto_own_roles: [document_controller]
// from the defaults). This fixture uses root-admin to keep the
// test self-contained without scaffolding a party folder; the
// non-root-admin DC path is covered by the standardroles tests.
mustWriteHelper(t, filepath.Join(root, ".zddc"), mustWriteHelper(t, filepath.Join(root, ".zddc"),
"admins:\n - alice@example.com\n"+ "admins:\n - alice@example.com\n"+
"roles:\n document_controller:\n members: [alice@example.com]\n") "roles:\n document_controller:\n members: [alice@example.com]\n")

View file

@ -145,11 +145,12 @@ func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWrit
// auto_own in defaults.zddc.yaml, so the unfenced creator grant // auto_own in defaults.zddc.yaml, so the unfenced creator grant
// fires here exactly as it would for a manual mkdir. // fires here exactly as it would for a manual mkdir.
if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) { if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) {
roles := zddc.AutoOwnRolesAt(cfg.Root, partyAbs)
var werr error var werr error
if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) { if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) {
werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email) werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email, roles)
} else { } else {
werr = zddc.WriteAutoOwnZddc(partyAbs, email) werr = zddc.WriteAutoOwnZddc(partyAbs, email, roles)
} }
if werr != nil { if werr != nil {
slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr) slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr)

View file

@ -836,6 +836,25 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
/* Inline action button appended to a toast by zddc.cap.handleForbidden
when an Elevate path is offered. Stops click propagation on its own
so clicking the button doesn't also dismiss the toast. */
.zddc-toast__action {
display: inline-block;
margin-left: 0.75rem;
padding: 0.25rem 0.75rem;
background: var(--accent, var(--text));
color: var(--bg);
border: none;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.zddc-toast__action:hover {
filter: brightness(1.1);
}
/* shared/elevation.css — admin-elevation toggle in the tool header. /* shared/elevation.css — admin-elevation toggle in the tool header.
Renders only for users with admin scope (handled by elevation.js; Renders only for users with admin scope (handled by elevation.js;
the placeholder is `.hidden` by default). When visible, sits left the placeholder is `.hidden` by default). When visible, sits left
@ -1515,7 +1534,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.20-dev · 2026-05-20 20:09:54 · 703449a-dirty</span></span> <span class="build-timestamp">v0.0.21</span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -2952,6 +2971,170 @@ body.is-elevated::after {
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})(); })();
// shared/cap.js — client-side capability helpers for permission gating.
//
// Three small helpers, exposed under window.zddc.cap, that wrap the
// server's verbs / /.profile/access?path / 403 missing_verb surface:
//
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
// /.profile/access?path=<urlpath> and
// memoises per-path for the session.
// Used by tools to gate top-of-page
// affordances (Publish, +Add row,
// +New folder) on PathVerbs.
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
// "rwcda"-subset) for the listed verb.
// Transition: falls back to
// node.writable for 'w' when verbs
// is absent, so the legacy field still
// drives gating on old listings.
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
// parses the JSON body for
// missing_verb and renders a toast.
// Offers "Elevate" when the path's
// /.profile/access?path= reports a
// path_can_elevate_grant covering the
// missing verb.
//
// Tools using this module must concat shared/cap.js AFTER shared/
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.cap) return;
var pathCache = new Map(); // path → AccessView (or null sentinel)
async function fetchAccess(path) {
// file:// pages have no server to fetch /.profile/access from;
// calling fetch() there logs a browser-level error before our
// catch even runs. Short-circuit so offline tools (browse on
// a picked folder, form opened from a file URL) silently
// degrade to "no path-scoped info, fall back to existing
// gating signals".
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
return null;
}
try {
var url = '/.profile/access';
if (path) url += '?path=' + encodeURIComponent(path);
var resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
// at(path) — fetch path-scoped access view, memoised per path
// within the page session. Cache is page-scoped: any elevation
// toggle forces a hard reload (see shared/elevation.js), which
// resets the cache so stale-after-elevation isn't a concern. Pass
// null/undefined for the global view (no ?path=).
async function at(path) {
var key = path || '';
if (pathCache.has(key)) return pathCache.get(key);
var view = await fetchAccess(path);
pathCache.set(key, view);
return view;
}
// has(node, verb) — check a per-entry verbs string for a single
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
// Transition shim: when node.verbs is absent, fall back to
// node.writable for 'w' so the legacy field keeps editor save
// buttons working on old listings — drop this fallback once every
// tool's loader sets node.verbs unconditionally.
function has(node, verb) {
if (!node) return false;
if (typeof node.verbs === 'string') {
return node.verbs.indexOf(verb) !== -1;
}
if (verb === 'w' && typeof node.writable === 'boolean') {
return node.writable;
}
return false;
}
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
var VERB_LABELS = {
r: 'read',
w: 'write',
c: 'create',
d: 'delete',
a: 'edit access rules'
};
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
// decide whether to offer an Elevate action. opts.context is an
// optional string prefix shown before the verb message ("Save",
// "Delete", etc.) — purely cosmetic.
//
// Best-effort: when the body isn't JSON or missing_verb is
// absent, falls back to a plain "Forbidden" toast. Returns the
// Promise so callers can await before chaining.
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
try {
var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
} catch (_e) { /* non-JSON body */ }
var prefix = opts.context ? (opts.context + ': ') : '';
var verbLabel = VERB_LABELS[missing] || missing || '';
var msg;
if (verbLabel) {
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
} else {
msg = prefix + 'Forbidden.';
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
// action appended to the toast message; clicking sets the
// elevation cookie and reloads, matching the header toggle.
var canOffer = false;
if (opts.path && missing) {
var view = await at(opts.path);
if (view && typeof view.path_can_elevate_grant === 'string'
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
canOffer = true;
}
}
var toastFn = (window.zddc && window.zddc.toast) || function () {};
var el = toastFn(msg, 'error', { durationMs: 8000 });
if (canOffer && el && el.appendChild) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'zddc-toast__action';
btn.textContent = 'Elevate';
btn.addEventListener('click', function (ev) {
ev.stopPropagation(); // don't dismiss the toast
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
window.zddc.elevation.setElevated(true);
window.location.reload();
}
});
el.appendChild(btn);
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})();
// shared/context-menu.js — generic context-menu framework exposed on // shared/context-menu.js — generic context-menu framework exposed on
// window.zddc.menu. Built so every ZDDC tool can drop a right-click // window.zddc.menu. Built so every ZDDC tool can drop a right-click
// menu (or any programmatically-opened menu) onto its UI without // menu (or any programmatically-opened menu) onto its UI without
@ -2963,8 +3146,11 @@ body.is-elevated::after {
// //
// `items` is an array (or a function returning an array, evaluated // `items` is an array (or a function returning an array, evaluated
// against `context` at open-time). Each entry is one of: // against `context` at open-time). Each entry is one of:
// { label, action, icon?, accel?, disabled?, visible?, danger? } // { label, action, icon?, accel?, disabled?, visible?, danger?, tooltip? }
// — a normal menu item; `action(ctx)` fires on click/Enter. // — a normal menu item; `action(ctx)` fires on click/Enter.
// `tooltip` (string or fn(ctx)) sets the row's title attribute —
// useful for explaining WHY a disabled item is unavailable
// ("You don't have write access here", etc.).
// { label, checked, action, ... } // { label, checked, action, ... }
// — toggle item; `checked` may be a bool or a fn(ctx). Renders // — toggle item; `checked` may be a bool or a fn(ctx). Renders
// a ✓ in the gutter when truthy. // a ✓ in the gutter when truthy.
@ -2975,10 +3161,10 @@ body.is-elevated::after {
// are collapsed automatically so callers can build items // are collapsed automatically so callers can build items
// conditionally without managing dividers. // conditionally without managing dividers.
// //
// Any of `label`, `checked`, `visible`, `disabled`, and `items` may // Any of `label`, `checked`, `visible`, `disabled`, `tooltip`, and
// be a function — each is invoked with the context object so callers // `items` may be a function — each is invoked with the context object
// can render fully context-aware menus from a single declarative // so callers can render fully context-aware menus from a single
// config. // declarative config.
// //
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a // Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
// submenu, ArrowLeft / Escape backs up one level (or closes if // submenu, ArrowLeft / Escape backs up one level (or closes if
@ -3100,6 +3286,10 @@ body.is-elevated::after {
row.classList.add('is-disabled'); row.classList.add('is-disabled');
row.setAttribute('aria-disabled', 'true'); row.setAttribute('aria-disabled', 'true');
} }
if ('tooltip' in item) {
var tip = resolve(item.tooltip, ctx);
if (tip) row.title = String(tip);
}
row.setAttribute('role', row.setAttribute('role',
hasSub ? 'menuitem' hasSub ? 'menuitem'
: (isToggle ? 'menuitemcheckbox' : 'menuitem')); : (isToggle ? 'menuitemcheckbox' : 'menuitem'));
@ -5440,6 +5630,17 @@ body.is-elevated::after {
return { status: 'invalid', errors: errs }; return { status: 'invalid', errors: errs };
} }
if (resp.status === 403) {
setRowState(rowId, 'errored');
if (window.zddc && window.zddc.cap) {
window.zddc.cap.handleForbidden(resp, {
context: 'Save row',
path: location.pathname
});
}
return { status: 'forbidden' };
}
// Other status — generic error. // Other status — generic error.
console.warn('[tables] save returned', resp.status); console.warn('[tables] save returned', resp.status);
setRowState(rowId, 'errored'); setRowState(rowId, 'errored');
@ -5519,6 +5720,17 @@ body.is-elevated::after {
return { status: 'invalid', errors: errs }; return { status: 'invalid', errors: errs };
} }
if (resp.status === 403) {
setRowState(rowId, 'errored');
if (window.zddc && window.zddc.cap) {
window.zddc.cap.handleForbidden(resp, {
context: 'Add row',
path: location.pathname
});
}
return { status: 'forbidden' };
}
console.warn('[tables] createRow returned', resp.status); console.warn('[tables] createRow returned', resp.status);
setRowState(rowId, 'errored'); setRowState(rowId, 'errored');
return { status: 'http-error', code: resp.status }; return { status: 'http-error', code: resp.status };
@ -6474,15 +6686,27 @@ body.is-elevated::after {
// clicked a header link, the URL bar, etc. without moving to // clicked a header link, the URL bar, etc. without moving to
// another row first). focusout fires for cell-to-cell moves // another row first). focusout fires for cell-to-cell moves
// too — relatedTarget being outside #table-root distinguishes. // too — relatedTarget being outside #table-root distinguishes.
//
// Deferred to next tick (setTimeout 0): the editor's commit
// path tears down its input element and then refocuses the
// owning cell. The remove fires focusout BEFORE the refocus
// runs, with relatedTarget=null (body briefly), so the naive
// sync check would mis-detect a "left the grid" event and
// fire flushAll redundantly alongside the selection-change
// save. Checking document.activeElement on the next tick
// gives the refocus time to settle.
const tableRoot = document.getElementById('table-root'); const tableRoot = document.getElementById('table-root');
if (tableRoot) { if (tableRoot) {
tableRoot.addEventListener('focusout', function (ev) { tableRoot.addEventListener('focusout', function (ev) {
const next = ev.relatedTarget; const next = ev.relatedTarget;
if (next && tableRoot.contains(next)) return; if (next && tableRoot.contains(next)) return;
setTimeout(function () {
if (tableRoot.contains(document.activeElement)) return;
const save = app.modules.save; const save = app.modules.save;
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) { if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
save.flushAll(); save.flushAll();
} }
}, 0);
}); });
} }
@ -6527,6 +6751,33 @@ body.is-elevated::after {
addRowBtn.addEventListener('keydown', function (ev) { addRowBtn.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev); if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev);
}); });
// Permission gate: fetch the path-scoped verbs for the
// current directory and disable + Add row when the
// cascade denies create. Async — the button shows up
// optimistically and disables a tick later if the
// server says no, which is the same race window every
// path-scoped fetch has. Server still gates the POST,
// so the worst case is a 403 toast on click.
if (window.zddc && window.zddc.cap) {
window.zddc.cap.at(location.pathname).then(function (view) {
if (!view) return;
var verbs = view.path_verbs || '';
if (verbs.indexOf('c') === -1) {
addRowBtn.classList.add('is-disabled');
addRowBtn.setAttribute('aria-disabled', 'true');
addRowBtn.title = "You don't have create access in this folder.";
// Swallow clicks so the no-op feedback is the
// tooltip, not a 403 toast on submission.
addRowBtn.addEventListener('click', function (ev) {
if (addRowBtn.classList.contains('is-disabled')) {
ev.preventDefault();
ev.stopPropagation();
}
}, true);
}
});
}
} }
} }
@ -7376,6 +7627,12 @@ body.is-elevated::after {
showStatus('Please correct the errors below.', 'error'); showStatus('Please correct the errors below.', 'error');
} else if (res.status === 403) { } else if (res.status === 403) {
showStatus('You are not allowed to submit here.', 'error'); showStatus('You are not allowed to submit here.', 'error');
if (window.zddc && window.zddc.cap) {
window.zddc.cap.handleForbidden(res, {
context: 'Submit',
path: app.context.submitUrl
});
}
} else if (res.status === 409) { } else if (res.status === 409) {
showStatus('A submission with this filename already exists.', 'error'); showStatus('A submission with this filename already exists.', 'error');
} else { } else {
@ -7476,6 +7733,29 @@ body.is-elevated::after {
const submitBtn = document.getElementById('submit-btn'); const submitBtn = document.getElementById('submit-btn');
if (submitBtn) { if (submitBtn) {
submitBtn.addEventListener('click', app.modules.post.submit); submitBtn.addEventListener('click', app.modules.post.submit);
// Pre-flight gate: hide Submit when the cascade denies
// create at the submission directory. Server still
// enforces on POST — this just avoids dangling an
// affordance that would 403. Submission directory is the
// parent of submitUrl; fall back to the page URL when
// submitUrl is absent (file:// / no-context mode).
if (window.zddc && window.zddc.cap && app.context && app.context.submitUrl) {
const subUrl = app.context.submitUrl;
const dir = subUrl.replace(/\/[^\/]*$/, '/') || subUrl;
window.zddc.cap.at(dir).then(function (view) {
if (!view) return;
const verbs = view.path_verbs || '';
if (verbs.indexOf('c') === -1) {
submitBtn.hidden = true;
const status = document.getElementById('form-status');
if (status) {
status.textContent = "You don't have permission to submit here.";
status.hidden = false;
status.classList.add('is-error');
}
}
});
}
} }
} }

View file

@ -1,12 +1,13 @@
package handler package handler
import ( import (
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"gopkg.in/yaml.v3"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
@ -35,13 +36,17 @@ func IsZddcFileRequest(urlPath string) bool {
// user who can read the directory can read its .zddc. // user who can read the directory can read its .zddc.
// On-disk: if <dir>/.zddc exists, its bytes are returned verbatim // On-disk: if <dir>/.zddc exists, its bytes are returned verbatim
// with Content-Type: application/yaml. // with Content-Type: application/yaml.
// Virtual: if it does not exist, a synthetic body is returned with a // Virtual: if it does not exist, the body is the cascade's
// cascade summary so the operator can see what rules are // leaf-level ZddcFile (what defaults.zddc.yaml's paths:
// effective at this depth. The synthetic body is clearly // tree declares for THIS exact directory, plus any
// marked with comments — PUT-saving its bytes back to the // virtual contributions threaded through by the walker)
// same URL (through the file API) materialises a real file. // marshalled as YAML. A header comment names the source
// The virtual response sets X-ZDDC-Source: virtual so the // and points at ?effective=1 for the composed view. The
// client can distinguish. // virtual body is itself valid YAML — PUT-saving it back
// (with or without edits) through the file API
// materialises a real on-disk override carrying exactly
// the bytes the user saved. The response sets
// X-ZDDC-Source: virtual:zddc so clients can distinguish.
func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) { func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
decider := DeciderFromContext(r) decider := DeciderFromContext(r)
@ -86,6 +91,16 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return return
} }
// ?effective=1 branch: return the composed cascade view as JSON.
// Distinct from the .zddc file itself — the YAML body is "what's
// defined at this level" (source of truth); this is "what's
// effective after merging every ancestor" (inspection only, not
// PUT-saveable as a .zddc).
if r.URL.Query().Get("effective") == "1" {
serveEffectiveZddc(cfg, dirURL, chain, w, r)
return
}
zddcPath := filepath.Join(abs, ".zddc") zddcPath := filepath.Join(abs, ".zddc")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/yaml; charset=utf-8") w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
@ -103,9 +118,15 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return return
} }
// No file on disk → synthetic placeholder body with a cascade // No file on disk → render the cascade's leaf level as YAML.
// summary so the user can see what's actually effective here. // What the user sees is the embedded defaults' declared shape
body := renderVirtualZddc(cfg.Root, abs, chain) // for this exact path; PUT-saving it back materialises an
// on-disk override verbatim.
body, err := renderVirtualZddc(chain)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("X-ZDDC-Source", "virtual:zddc") w.Header().Set("X-ZDDC-Source", "virtual:zddc")
if r.Method == http.MethodHead { if r.Method == http.MethodHead {
return return
@ -113,75 +134,168 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body)) _, _ = w.Write([]byte(body))
} }
// renderVirtualZddc produces a self-describing YAML placeholder for a // renderVirtualZddc produces a YAML body for a directory that has no
// directory that has no .zddc on disk. The body is valid YAML (parses // .zddc on disk. The body is the cascade's leaf-level ZddcFile —
// to an empty document) so a downstream YAML tool isn't fazed; the // i.e. what defaults.zddc.yaml's paths: tree declares for this exact
// commentary lives in comments. Each ancestor's contribution is // directory, plus any contributions the walker threaded through. The
// summarised so the reader sees exactly what's effective at this // goal is to expose the embedded defaults' source of truth: a new
// depth. // user opening the virtual .zddc here sees, in the same yaml shape
func renderVirtualZddc(fsRoot, dirAbs string, chain zddc.PolicyChain) string { // they would write themselves, what behavior is currently declared
var b strings.Builder // at this level. A header comment names the source and points at
fmt.Fprintf(&b, "# Virtual .zddc — no file on disk at this directory yet.\n") // ?effective=1 for the composed view across the chain.
fmt.Fprintf(&b, "# Rules below are inherited from ancestors. Edit + save\n") //
fmt.Fprintf(&b, "# (PUT) through the YAML editor in browse (admin-only)\n") // PUT-saving these bytes back through the file API materialises a
fmt.Fprintf(&b, "# to override at this level — the save materialises a\n") // real on-disk override carrying exactly the saved content — the
fmt.Fprintf(&b, "# real file here.\n") // virtual body is a template, not a contract; the operator can
fmt.Fprintf(&b, "#\n") // trim / extend / overwrite freely.
fmt.Fprintf(&b, "# Effective cascade at %s:\n", urlPathOf(fsRoot, dirAbs)) func renderVirtualZddc(chain zddc.PolicyChain) (string, error) {
var leaf zddc.ZddcFile
if n := len(chain.Levels); n > 0 {
leaf = chain.Levels[n-1]
}
// Walk the levels from root down. Each ZddcFile in chain.Levels var b strings.Builder
// corresponds to one ancestor (root, .../, ..., dirAbs). Show only b.WriteString("# Virtual .zddc — no file on disk at this directory.\n")
// the levels that contributed something non-empty. b.WriteString("# The content below is what the embedded defaults\n")
dirs := chainDirs(fsRoot, dirAbs) b.WriteString("# (defaults.zddc.yaml's paths: tree) declare for this\n")
any := false b.WriteString("# exact path. Edit and save through the YAML editor in\n")
for i, lvl := range chain.Levels { b.WriteString("# browse to materialise a real .zddc here carrying your\n")
var levelDir string b.WriteString("# changes; the bytes you save become the override\n")
if i < len(dirs) { b.WriteString("# verbatim (no merge, no synthesis — .zddc files drive\n")
levelDir = dirs[i] b.WriteString("# policy and are the single source of truth).\n")
} else { b.WriteString("#\n")
levelDir = fsRoot b.WriteString("# For the COMPOSED effective config across the whole\n")
b.WriteString("# cascade (all ancestors merged), query:\n")
b.WriteString("# GET <this-url>?effective=1 (JSON, not a .zddc).\n")
if isZeroZddcFile(leaf) {
b.WriteString("#\n")
b.WriteString("# No rules declared at this exact level — every rule\n")
b.WriteString("# currently in effect here is inherited from ancestors.\n")
b.WriteString("{}\n")
return b.String(), nil
} }
entry := summariseLevel(lvl)
if entry == "" { body, err := yaml.Marshal(&leaf)
continue if err != nil {
return "", err
} }
any = true b.WriteByte('\n')
fmt.Fprintf(&b, "#\n# from %s/.zddc:\n%s", b.Write(body)
urlPathOf(fsRoot, levelDir), entry) return b.String(), nil
}
if !any {
fmt.Fprintf(&b, "# (no ancestor .zddc contributes any rule)\n")
}
fmt.Fprintf(&b, "\n# --- placeholder body (empty) ---\n")
fmt.Fprintf(&b, "{}\n")
return b.String()
} }
// summariseLevel produces a comment block describing one .zddc level's // effectiveSourceView is the wire shape for one entry in the
// non-empty contributions (title, acl, admins, apps, tables). Empty // `sources` array of the ?effective=1 response. Level matches
// levels return "" so the caller can skip them. // zddc.SourceEntry.Level (-1 = embedded baseline, 0+ = chain index);
func summariseLevel(lvl zddc.ZddcFile) string { // URL is the directory URL of that level (or "<embedded>" for the
var b strings.Builder // baseline); Contributed lists the top-level fields the level
if lvl.Title != "" { // declared.
fmt.Fprintf(&b, "# title: %q\n", lvl.Title) type effectiveSourceView struct {
} Level int `json:"level"`
if len(lvl.ACL.Permissions) > 0 { URL string `json:"url"`
fmt.Fprintf(&b, "# acl.permissions: %v\n", lvl.ACL.Permissions) Contributed []string `json:"contributed,omitempty"`
} }
if len(lvl.Admins) > 0 {
fmt.Fprintf(&b, "# admins: %v\n", lvl.Admins) // effectiveZddcView is the wire shape for the ?effective=1 response.
} // Merged is the composed cascade as a ZddcFile (same struct shape the
if len(lvl.Apps) > 0 { // editor consumes for an on-disk .zddc; client-side renderers can
fmt.Fprintf(&b, "# apps:\n") // reuse the same parser). Sources lists per-level contributions so
for k, v := range lvl.Apps { // the user can trace any value back to its origin without re-walking
fmt.Fprintf(&b, "# %s: %s\n", k, v) // the cascade by hand.
} type effectiveZddcView struct {
} URLPath string `json:"url_path"`
if len(lvl.Tables) > 0 { Merged zddc.ZddcFile `json:"merged"`
fmt.Fprintf(&b, "# tables:\n") Sources []effectiveSourceView `json:"sources"`
for k, v := range lvl.Tables { }
fmt.Fprintf(&b, "# %s: %s\n", k, v)
} // serveEffectiveZddc writes the JSON composed-cascade view for the
} // .zddc URL. Same ACL as the YAML view (already enforced by the
return b.String() // caller).
func serveEffectiveZddc(cfg config.Config, dirURL string, chain zddc.PolicyChain, w http.ResponseWriter, r *http.Request) {
merged, sources := zddc.EffectiveZddc(chain)
levelURLs := levelURLsFor(cfg.Root, dirURL, len(chain.Levels))
view := effectiveZddcView{
URLPath: dirURL,
Merged: merged,
Sources: make([]effectiveSourceView, 0, len(sources)),
}
for _, s := range sources {
entry := effectiveSourceView{Level: s.Level, Contributed: s.Contributed}
if s.Level < 0 {
entry.URL = "<embedded>"
} else if s.Level < len(levelURLs) {
entry.URL = levelURLs[s.Level] + ".zddc"
}
view.Sources = append(view.Sources, entry)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-ZDDC-Source", "virtual:effective")
if r.Method == http.MethodHead {
return
}
writeJSON(w, view)
}
// levelURLsFor maps each chain level index to its directory URL. The
// chain walks dirs root→leaf so levelURLs[0] = "/", levelURLs[1] is
// the first segment, etc. Length must equal len(chain.Levels).
//
// Used by serveEffectiveZddc to populate SourceEntry.URL — clients
// receive concrete .zddc URLs they can navigate to rather than bare
// integer indices.
func levelURLsFor(_, dirURL string, n int) []string {
dirURL = strings.TrimSuffix(dirURL, "/")
out := make([]string, n)
out[0] = "/"
if dirURL == "" || n == 1 {
return out
}
segs := strings.Split(strings.TrimPrefix(dirURL, "/"), "/")
cur := ""
for i, seg := range segs {
if i+1 >= n {
break
}
cur += "/" + seg
out[i+1] = cur + "/"
}
return out
}
// isZeroZddcFile reports whether zf carries no declarations a user
// would want to see — every field is its zero value. Used to switch
// the virtual body between the rich path (marshal the leaf) and the
// empty-placeholder path (just say "nothing declared here").
//
// The ACL substruct's Inherit pointer being nil is part of "zero"
// here; an explicit inherit: false is itself a declaration worth
// surfacing.
func isZeroZddcFile(zf zddc.ZddcFile) bool {
return zf.Title == "" &&
zf.AppsPubKey == "" &&
zf.CreatedBy == "" &&
zf.DefaultTool == "" &&
zf.DirTool == "" &&
zf.ReceivedPath == "" &&
zf.PlannedReviewDate == "" &&
zf.PlannedResponseDate == "" &&
zf.ACL.Inherit == nil &&
zf.AutoOwn == nil &&
zf.AutoOwnFenced == nil &&
zf.Virtual == nil &&
zf.DropTarget == nil &&
zf.Convert == nil &&
len(zf.ACL.Permissions) == 0 &&
len(zf.Admins) == 0 &&
len(zf.Apps) == 0 &&
len(zf.Tables) == 0 &&
len(zf.Display) == 0 &&
len(zf.Roles) == 0 &&
len(zf.FieldCodes) == 0 &&
len(zf.Records) == 0 &&
len(zf.AvailableTools) == 0 &&
len(zf.Worm) == 0 &&
len(zf.Paths) == 0
} }

View file

@ -2,6 +2,7 @@ package handler
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@ -68,10 +69,20 @@ func TestServeZddcFile_ExistingFile(t *testing.T) {
} }
} }
// TestServeZddcFile_VirtualDefault — when no .zddc is on disk at the
// requested directory, the body is the cascade's leaf-level ZddcFile
// marshalled as YAML, prefixed by a header comment explaining what
// the file is and pointing at ?effective=1 for the composed view.
//
// At /Project/.zddc with no on-disk file, the leaf is the embedded
// defaults' paths.* contribution — i.e. the project-scoped baseline
// (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
// zddc/internal/zddc and is parsed at every cascade walk.
func TestServeZddcFile_VirtualDefault(t *testing.T) { func TestServeZddcFile_VirtualDefault(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"title: bootstrap\nacl:\n permissions:\n \"*\": rwcda\n")
// Directory exists but has no .zddc. // Directory exists but has no .zddc.
subDir := filepath.Join(root, "Project") subDir := filepath.Join(root, "Project")
if err := os.Mkdir(subDir, 0o755); err != nil { if err := os.Mkdir(subDir, 0o755); err != nil {
@ -93,15 +104,273 @@ func TestServeZddcFile_VirtualDefault(t *testing.T) {
} }
body := rec.Body.String() body := rec.Body.String()
if !strings.Contains(body, "Virtual .zddc") { if !strings.Contains(body, "Virtual .zddc") {
t.Errorf("body missing virtual marker: %q", body) t.Errorf("body missing virtual header comment: %q", body)
} }
// Should show the root's title from the cascade. if !strings.Contains(body, "?effective=1") {
if !strings.Contains(body, "bootstrap") { t.Errorf("body missing pointer to the composed-view query: %q", body)
t.Errorf("body missing root cascade summary: %q", body) }
// The embedded defaults declare project_team: r and
// observer: r at paths.*. Confirm both surface so the user
// sees the project-scoped baseline.
if !strings.Contains(body, "project_team: r") {
t.Errorf("body missing project_team grant from embedded defaults: %q", body)
}
if !strings.Contains(body, "observer: r") {
t.Errorf("body missing observer grant from embedded defaults: %q", body)
}
// The paths: subtree below should include archive (the only
// physical project-root child) and the virtual aggregators.
if !strings.Contains(body, "archive:") {
t.Errorf("body missing archive subtree: %q", body)
}
}
// TestServeZddcFile_VirtualEmpty — at a directory the embedded
// defaults' paths: tree does NOT cover, the body collapses to the
// header comment + an empty-document placeholder ({}). The user
// sees "no rules declared at this exact level".
func TestServeZddcFile_VirtualEmpty(t *testing.T) {
root := t.TempDir()
// /Project/random-subfolder/ is not declared in the embedded
// defaults' paths tree (paths.* matches the project name, but
// no child path matches "random-subfolder").
deep := filepath.Join(root, "Project", "random-subfolder")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/Project/random-subfolder/.zddc", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeZddcFile(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Virtual .zddc") {
t.Errorf("body missing virtual header: %q", body)
}
if !strings.Contains(body, "{}") {
t.Errorf("undeclared-level body should end in {}: %q", body)
}
if !strings.Contains(body, "inherited from ancestors") {
t.Errorf("undeclared-level body should explain inheritance: %q", body)
}
}
// TestServeZddcFile_Effective_BasicCompose — ?effective=1 returns the
// merged composed view across embedded baseline + on-disk levels.
// Body is JSON with the merged ZddcFile and per-level source list.
func TestServeZddcFile_Effective_BasicCompose(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"alice@example.com\": rwcda\n")
proj := filepath.Join(root, "Project")
if err := os.Mkdir(proj, 0o755); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(proj, ".zddc"),
"title: My Project\n")
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/Project/.zddc?effective=1", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeZddcFile(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
t.Errorf("Content-Type = %q, want application/json", got)
}
if got := rec.Header().Get("X-ZDDC-Source"); got != "virtual:effective" {
t.Errorf("X-ZDDC-Source = %q, want virtual:effective", got)
}
var view effectiveZddcView
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
t.Fatalf("decode: %v (body: %s)", err, rec.Body.String())
}
if view.URLPath != "/Project" {
t.Errorf("url_path = %q, want /Project", view.URLPath)
}
// Merged should carry alice's grant (from root) AND the title
// from /Project, AND the project_team grant from the embedded
// defaults' paths.* contribution.
if view.Merged.ACL.Permissions["alice@example.com"] != "rwcda" {
t.Errorf("merged.acl.permissions missing alice's grant: %+v", view.Merged.ACL.Permissions)
}
if view.Merged.ACL.Permissions["project_team"] != "r" {
t.Errorf("merged.acl.permissions missing project_team (from embedded defaults paths.*): %+v", view.Merged.ACL.Permissions)
}
if view.Merged.Title != "My Project" {
t.Errorf("merged.title = %q, want My Project (from /Project/.zddc)", view.Merged.Title)
}
// Sources should include the embedded baseline (level -1) and
// the two on-disk levels.
var levels []int
for _, s := range view.Sources {
levels = append(levels, s.Level)
}
wantLevels := map[int]bool{-1: true, 0: true, 1: true}
for _, l := range levels {
delete(wantLevels, l)
}
if len(wantLevels) > 0 {
t.Errorf("missing source levels %v in %v", wantLevels, levels)
}
// Per-level URLs are populated.
for _, s := range view.Sources {
if s.Level == -1 && s.URL != "<embedded>" {
t.Errorf("embedded source url = %q, want <embedded>", s.URL)
}
if s.Level == 0 && s.URL != "/.zddc" {
t.Errorf("root source url = %q, want /.zddc", s.URL)
}
if s.Level == 1 && s.URL != "/Project/.zddc" {
t.Errorf("project source url = %q, want /Project/.zddc", s.URL)
}
}
}
// TestServeZddcFile_Effective_InheritFence — inherit:false at a level
// drops every ancestor (including the embedded baseline) from the
// composed view. Only the fence-and-below contribute.
func TestServeZddcFile_Effective_InheritFence(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"alice@example.com\": rwcda\n")
proj := filepath.Join(root, "Closed")
if err := os.Mkdir(proj, 0o755); err != nil {
t.Fatal(err)
}
// inherit:false on Closed/.zddc — root + embedded both drop
// out of the visible chain.
// Top-level inherit:false drops EVERY ancestor including the
// embedded baseline. (ACL.inherit:false would only fence ACL
// evaluation — roles, paths-tree, and embedded baseline still
// cascade through, which is a separate test.)
mustWrite(t, filepath.Join(proj, ".zddc"),
"inherit: false\n"+
"acl:\n inherit: false\n permissions:\n \"bob@example.com\": rwcda\n")
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
// Bob has the only grant inside the fence; alice's root grant
// is hidden by inherit:false so she'd 404 on the read gate.
req := httptest.NewRequest(http.MethodGet, "/Closed/.zddc?effective=1", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "bob@example.com"))
rec := httptest.NewRecorder()
ServeZddcFile(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var view effectiveZddcView
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
t.Fatalf("decode: %v", err)
}
// Alice's root grant must be invisible behind the fence.
if _, ok := view.Merged.ACL.Permissions["alice@example.com"]; ok {
t.Errorf("alice's root grant should be hidden by fence; got %+v", view.Merged.ACL.Permissions)
}
// Bob's grant at Closed/ is visible.
if view.Merged.ACL.Permissions["bob@example.com"] != "rwcda" {
t.Errorf("bob's fence-level grant missing: %+v", view.Merged.ACL.Permissions)
}
// Embedded baseline (level -1) must not appear in sources — the
// fence zeroed it.
for _, s := range view.Sources {
if s.Level == -1 {
t.Errorf("embedded baseline leaked past inherit:false fence: %+v", s)
}
if s.Level == 0 {
t.Errorf("root /.zddc leaked past inherit:false fence: %+v", s)
}
}
}
// TestServeZddcFile_Effective_RoleMemberUnion — roles defined at
// multiple levels show the union of members (the runtime ACL
// evaluator uses lookupRoleMembers' union, and the composed view
// must match).
func TestServeZddcFile_Effective_RoleMemberUnion(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"alice@example.com\": r\n"+
"roles:\n document_controller:\n members:\n - root-dc@example.com\n")
proj := filepath.Join(root, "Project")
if err := os.Mkdir(proj, 0o755); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(proj, ".zddc"),
"roles:\n document_controller:\n members:\n - project-dc@example.com\n")
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/Project/.zddc?effective=1", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeZddcFile(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var view effectiveZddcView
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
t.Fatalf("decode: %v", err)
}
dc, ok := view.Merged.Roles["document_controller"]
if !ok {
t.Fatalf("merged.roles missing document_controller: %+v", view.Merged.Roles)
}
wantMembers := map[string]bool{
"root-dc@example.com": true,
"project-dc@example.com": true,
}
for _, m := range dc.Members {
delete(wantMembers, m)
}
if len(wantMembers) > 0 {
t.Errorf("document_controller members missing %v; got %v", wantMembers, dc.Members)
}
}
// TestServeZddcFile_VirtualPerPartyWorking — a deeper path declared
// by the embedded defaults (archive/<party>/working/) shows its own
// rich subtree: default_tool, available_tools, auto_own, etc.
func TestServeZddcFile_VirtualPerPartyWorking(t *testing.T) {
root := t.TempDir()
deep := filepath.Join(root, "Project", "archive", "Acme", "working")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/Project/archive/Acme/working/.zddc", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeZddcFile(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
"default_tool: browse", // working/ default_tool
"auto_own: true", // working/ creator owns subdirs
"drop_target: true", // upload zone
"classifier", // available_tools includes classifier
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q at archive/<party>/working/: %s", want, body)
} }
// Should parse as valid YAML (empty document or {} at the end).
if !strings.Contains(body, "{}") {
t.Errorf("body missing placeholder body: %q", body)
} }
} }

View file

@ -272,6 +272,141 @@ func (chain PolicyChain) EffectiveRecordRule(basename string) (string, RecordRul
return pattern, merged, true return pattern, merged, true
} }
// SourceEntry names one cascade contribution to an EffectiveZddc
// composition. Level -1 is the embedded defaults baseline (chain.
// Embedded); levels 0+ index into chain.Levels (root→leaf). Contributed
// lists the top-level ZddcFile field names this level supplied a non-
// zero value for — used by inspection clients to answer "where does
// this value come from?" without re-walking the cascade.
type SourceEntry struct {
Level int `json:"level"`
Contributed []string `json:"contributed,omitempty"`
}
// EffectiveZddc composes the cascade into a single ZddcFile by walking
// chain.Embedded then chain.Levels[VisibleStart..] through mergeOverlay,
// and folding the cross-level Roles union (via lookupRoleMembers) into
// merged.Roles so the result reflects the same role membership the
// runtime ACL evaluator sees.
//
// Returned alongside is a per-source list of which top-level fields
// each contributing level declared. Caller maps SourceEntry.Level to a
// URL (-1 = embedded baseline; 0..len(chain.Levels)-1 = dirs along the
// walk from fsRoot to the requested directory).
//
// Returns the zero ZddcFile + nil sources when the chain is empty.
// Used by the ?effective=1 query on /.zddc — distinct from the .zddc
// file itself, which serves only what's defined at the leaf level.
func EffectiveZddc(chain PolicyChain) (ZddcFile, []SourceEntry) {
if len(chain.Levels) == 0 {
return ZddcFile{}, nil
}
sources := make([]SourceEntry, 0, len(chain.Levels)+1)
var merged ZddcFile
// Embedded baseline (skipped when an inherit:false fence dropped
// it; cascade.go zeroes chain.Embedded in that case).
if c := nonZeroZddcFields(chain.Embedded); len(c) > 0 {
merged = mergeOverlay(merged, chain.Embedded)
sources = append(sources, SourceEntry{Level: -1, Contributed: c})
}
leafIdx := len(chain.Levels) - 1
floor := chain.VisibleStart(leafIdx)
for i := floor; i <= leafIdx; i++ {
lvl := chain.Levels[i]
if c := nonZeroZddcFields(lvl); len(c) > 0 {
merged = mergeOverlay(merged, lvl)
sources = append(sources, SourceEntry{Level: i, Contributed: c})
}
}
// Roles: mergeOverlay does per-level name-keyed replacement, but
// the runtime evaluator unions members across levels via
// lookupRoleMembers (handling reset:true and the embedded
// baseline). Re-resolve every role name reachable in the visible
// chain so merged.Roles matches what ACL evaluation sees.
roleNames := collectRoleNames(chain, floor, leafIdx)
if len(roleNames) > 0 {
out := make(map[string]Role, len(roleNames))
for _, name := range roleNames {
members, defined := lookupRoleMembers(chain, leafIdx, name)
if !defined {
continue
}
out[name] = Role{Members: members}
}
merged.Roles = out
} else {
merged.Roles = nil
}
return merged, sources
}
// nonZeroZddcFields returns the names of top-level ZddcFile fields zf
// has populated. Field names match the yaml tags (so "acl" not "ACL").
// Used to populate SourceEntry.Contributed.
func nonZeroZddcFields(zf ZddcFile) []string {
var out []string
add := func(name string, cond bool) {
if cond {
out = append(out, name)
}
}
add("title", zf.Title != "")
add("acl", len(zf.ACL.Permissions) > 0 || zf.ACL.Inherit != nil)
add("admins", len(zf.Admins) > 0)
add("apps", len(zf.Apps) > 0)
add("apps_pubkey", zf.AppsPubKey != "")
add("tables", len(zf.Tables) > 0)
add("display", len(zf.Display) > 0)
add("convert", zf.Convert != nil)
add("roles", len(zf.Roles) > 0)
add("created_by", zf.CreatedBy != "")
add("default_tool", zf.DefaultTool != "")
add("dir_tool", zf.DirTool != "")
add("auto_own", zf.AutoOwn != nil)
add("auto_own_fenced", zf.AutoOwnFenced != nil)
add("virtual", zf.Virtual != nil)
add("drop_target", zf.DropTarget != nil)
add("worm", zf.Worm != nil)
add("available_tools", len(zf.AvailableTools) > 0)
add("received_path", zf.ReceivedPath != "")
add("planned_review_date", zf.PlannedReviewDate != "")
add("planned_response_date", zf.PlannedResponseDate != "")
add("field_codes", len(zf.FieldCodes) > 0)
add("records", len(zf.Records) > 0)
add("paths", len(zf.Paths) > 0)
return out
}
// collectRoleNames returns every role name that has a definition in
// any visible level (or the embedded baseline). Used by EffectiveZddc
// to know which roles to resolve via lookupRoleMembers — without it
// we'd miss roles declared only at an ancestor not directly merged at
// the leaf level (since per-level mergeOverlay replaces Roles by key,
// not by union).
func collectRoleNames(chain PolicyChain, floor, leafIdx int) []string {
seen := make(map[string]struct{})
for i := floor; i <= leafIdx; i++ {
for name := range chain.Levels[i].Roles {
seen[name] = struct{}{}
}
}
for name := range chain.Embedded.Roles {
seen[name] = struct{}{}
}
if len(seen) == 0 {
return nil
}
out := make([]string, 0, len(seen))
for name := range seen {
out = append(out, name)
}
return out
}
// InvalidateCache removes the cached policy for dirPath and all descendants. // InvalidateCache removes the cached policy for dirPath and all descendants.
func InvalidateCache(dirPath string) { func InvalidateCache(dirPath string) {
dirPath = filepath.Clean(dirPath) dirPath = filepath.Clean(dirPath)

View file

@ -34,22 +34,53 @@ acl:
# the reset are then excluded. # the reset are then excluded.
# #
# document_controller — the people who file into # document_controller — the people who file into
# archive/<party>/received/ and issued/ (WORM zones). They get # archive/<party>/received/ and issued/ (WORM zones). They get:
# read+write-once-create there (via the worm: lists below) and # - rwcda at every archive/<party>/ via the role grant written
# read/write elsewhere in a project, plus subtree-admin of the # into each party's auto-own .zddc (auto_own_roles below).
# per-party working/ + staging/ + reviewing/ so they can stand up # Cascade carries rwcda down to descendants by default.
# and manage drafting/transmittal/review folders. They are NOT # - read+write-once-create at received/issued via the worm:
# subtree-admin of archive/<party>/, so the WORM constraint still # lists (the WORM mask strips w/d/a even though the role
# binds them in received/issued. Plan-Review approval is part of # grant supplies rwcda at the party level above).
# this role by design — there is no separate `approver` role; # - rwcd explicit at incoming/ and staging/ (the QC and
# two-person sign-off, when needed, is expressed via per-folder # transmittal-out workflows need `d` to move files between
# `.zddc` overrides rather than baked-in roles. # slots; the explicit grants shadow the inherited rwcda
# to make the intent visible).
# - rwc at archive/ so they can create party subfolders.
#
# NOT a subtree-admin anywhere. There is no `admins:` entry for
# the role — DCs cannot bypass WORM (only worm-create via the
# list) and cannot reach inside fenced working homes. Admin
# elevation is reserved for the root admins: list (the human
# escape hatch for mis-filed documents or recovery).
#
# Plan-Review approval is part of this role by design — there is
# no separate `approver` role; two-person sign-off, when needed,
# is expressed via per-folder `.zddc` overrides rather than
# baked-in roles.
# #
# project_team — everyone working on a project. Read-only across # project_team — everyone working on a project. Read-only across
# the project. Their own archive/<party>/working/<email>/ home and # the project by default, with a one-way ratchet through the
# anything they create under incoming/ get a creator-owned auto- # in-flight slots:
# own .zddc (rwcda) which wins via deepest-match, so "read-only #
# except what I own" falls out of the cascade with no special rule. # working/ cr — create + read; the auto_own_fenced child
# gives the creator rwcda in their own home,
# fenced from siblings
# staging/ cr — drop + read, no modify (after drop, the
# doc-controller is the only one who can
# change it)
# reviewing/ cr — create + read; auto_own (unfenced) gives
# creator rwcda in their iteration folder,
# siblings see it via project-level :r
# received/ r — WORM zone; only document_controller can
# file (and even they need elevation to edit)
# issued/ r — WORM zone; published, immutable
# incoming/ r — counterparty's drop zone (project_team
# observers it, doc_controller QCs it)
#
# "Each handoff drops the role's modify rights for the previous
# slot." That's the model — project_team works freely in
# working/, commits to staging/, and from there the doc-
# controller takes over.
# #
# observer — pure read-only across the project. Like project_team # observer — pure read-only across the project. Like project_team
# but with no auto-own home: an observer who somehow created a # but with no auto-own home: an observer who somehow created a
@ -197,19 +228,31 @@ paths:
paths: paths:
# Second segment under archive/ is the party name. # Second segment under archive/ is the party name.
"*": "*":
# When the doc controller creates a party folder, an # When the doc controller creates a party folder, the
# auto-own .zddc grants them rwcda there (UNFENCED — so # auto-own .zddc grants:
# the project-level project_team:r still cascades through # - the creator's email rwcda (the standard auto_own
# to received/issued). That lets them set up the # mechanism)
# counterparty's own .zddc afterward. # - the document_controller role rwcda (auto_own_roles
# below) so any DC in the role has full authority at
# every party, not just the parties they personally
# mkdir'd
#
# UNFENCED — so the project-level project_team:r still
# cascades through to received/issued/incoming. That
# lets the DC who created the party set up the counter-
# party's own .zddc afterward (e.g. granting them cr at
# incoming/).
#
# No `admins:` here by design. The DC role gets full
# authority via the role grant in the auto-own .zddc, not
# via subtree-admin status — so WORM masks at
# received/issued still bind them (they file write-once
# via the worm: list), and per-user fenced homes under
# working/ stay private to their creators. Admin
# elevation is reserved for the root admins list (the
# actual sudo-style escape hatch).
auto_own: true auto_own: true
# Doc controller is subtree-admin of this party folder — auto_own_roles: [document_controller]
# full manage authority over the in-flight lifecycle
# slots (working/staging/reviewing) declared below. The
# WORM constraint on received/issued is enforced by the
# cascade's worm: lists, not by admin grants, so they
# still file write-once into those slots.
admins: [document_controller]
# SSR record: the party folder's ssr.yaml carries this # SSR record: the party folder's ssr.yaml carries this
# party's vendor / contract / status data. Scoped by # party's vendor / contract / status data. Scoped by
# filename pattern so the lock on `kind` only applies to # filename pattern so the lock on `kind` only applies to
@ -324,9 +367,49 @@ paths:
# level <project>/{working,staging,reviewing} virtuals # level <project>/{working,staging,reviewing} virtuals
# (declared above) are folder-nav views over these # (declared above) are folder-nav views over these
# canonical per-party slots. # canonical per-party slots.
# ── In-flight ratchet ───────────────────────────────
#
# The lifecycle slots form a one-way handoff:
#
# working/ → staging/ → issued/ (WORM)
# (full) (cr) (worm cr)
#
# At each step the previous role's modify rights drop:
# project_team iterates freely in working/; when they
# promote to staging/ they can't change it without doc-
# controller help; when DC publishes to issued/ even
# they can't change it without elevation. Each ACL
# grant below is the verb-set the ROLE keeps at that
# step; auto_own + auto_own_fenced sub-folder grants
# layer per-creator ownership on top of these.
working: working:
default_tool: browse default_tool: browse
available_tools: [browse, classifier] available_tools: [browse, classifier]
# Project_team gets read + create here so they can
# mkdir their own home folder (and any shared sub-
# folders). The auto_own_fenced declaration at the
# `*` child below makes the new folder a private home
# with rwcda for the creator (fenced from ancestors,
# so collaborators only join after the owner edits
# the home's .zddc to grant them access).
#
# `cr` instead of just `c` so an existing file at
# working/ root stays readable to all team members
# (cascade is per-level deepest-match — a single `c`
# would shadow the project-level `r`).
#
# `document_controller: rwcda` is restated here so a
# DC whose email is ALSO matched by project_team
# (typical when project_team is `*@example.com`) gets
# the higher grant via within-level union. Without
# the restatement, the cascade's deepest-level-wins
# would pick project_team's cr and shadow the DC's
# rwcda inherited from the party's auto-own .zddc.
# Same pattern applied at staging/ and reviewing/.
acl:
permissions:
project_team: cr
document_controller: rwcda
# working/ auto-owns the first creator + the per-user # working/ auto-owns the first creator + the per-user
# homes below. # homes below.
auto_own: true auto_own: true
@ -345,6 +428,32 @@ paths:
staging: staging:
default_tool: transmittal default_tool: transmittal
available_tools: [transmittal, classifier] available_tools: [transmittal, classifier]
# The ratchet step from working/. project_team gets
# `cr` — they can drop files (PUT new files at
# staging/) and read what's there, but cannot edit or
# delete after the drop. Once a file is in staging it
# belongs to the doc-controller workflow; the team
# member needs to ask DC to change it.
#
# Convention: project_team drops FILES at staging/,
# not sub-folders. A sub-folder mkdir'd by project_
# team would trigger auto_own and grant them rwcda
# inside their own sub-folder (auto_own is path-keyed,
# not role-keyed — it fires for any creator). The
# auto_own here is preserved for DC's per-transmittal
# mkdir flow; project_team can keep to file drops to
# honour the "can't alter after" intent.
#
# DC gets rwcda explicitly — `d` for the cut to issued/,
# `a` so Plan Review can write the staging/<tracking>/.zddc
# the composite endpoint scaffolds. Restated here (not
# inherited from the party-level role grant) so the
# within-level union dominates project_team's cr for
# any DC matched by the team wildcard.
acl:
permissions:
project_team: cr
document_controller: rwcda
auto_own: true auto_own: true
drop_target: true drop_target: true
reviewing: reviewing:
@ -359,5 +468,21 @@ paths:
# from the party-level admins:) so the doc # from the party-level admins:) so the doc
# controller can author per-folder .zddc files # controller can author per-folder .zddc files
# (originator ACL, planned_date). # (originator ACL, planned_date).
#
# project_team gets `cr` so the originating team can
# create review-iteration folders alongside the
# Plan-Review-scaffolded ones. auto_own (unfenced
# here, unlike working/) gives the creator rwcda
# inside; siblings see the iteration via the project-
# level project_team:r cascade.
#
# document_controller: rwcda restated for the same
# reason as working/ + staging/ — keeps a DC matched
# by the project_team wildcard at full authority via
# within-level union.
acl:
permissions:
project_team: cr
document_controller: rwcda
auto_own: true auto_own: true
drop_target: true drop_target: true

View file

@ -130,6 +130,7 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
absPath string absPath string
autoOwn bool autoOwn bool
fenced bool fenced bool
roles []string
} }
var freshlyCreated []created var freshlyCreated []created
@ -224,10 +225,15 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
_ = i _ = i
autoOwn := AutoOwnAt(fsRoot, pathSoFar) autoOwn := AutoOwnAt(fsRoot, pathSoFar)
fenced := autoOwn && AutoOwnFencedAt(fsRoot, pathSoFar) fenced := autoOwn && AutoOwnFencedAt(fsRoot, pathSoFar)
var roles []string
if autoOwn {
roles = AutoOwnRolesAt(fsRoot, pathSoFar)
}
freshlyCreated = append(freshlyCreated, created{ freshlyCreated = append(freshlyCreated, created{
absPath: pathSoFar, absPath: pathSoFar,
autoOwn: autoOwn, autoOwn: autoOwn,
fenced: fenced, fenced: fenced,
roles: roles,
}) })
} }
@ -235,7 +241,10 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
// created. Skip if no principal email is available (anonymous or // created. Skip if no principal email is available (anonymous or
// system writes). The fenced variant is used at per-user home // system writes). The fenced variant is used at per-user home
// folders under working/ — private by default; owner can later // folders under working/ — private by default; owner can later
// edit the .zddc to add collaborators. // edit the .zddc to add collaborators. Role grants (from the
// cascade's auto_own_roles list) are written alongside the
// creator email so role-level peer authority survives without
// needing a subtree-admin grant.
if principalEmail != "" { if principalEmail != "" {
for _, c := range freshlyCreated { for _, c := range freshlyCreated {
if !c.autoOwn { if !c.autoOwn {
@ -243,9 +252,9 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
} }
var werr error var werr error
if c.fenced { if c.fenced {
werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail) werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail, c.roles)
} else { } else {
werr = WriteAutoOwnZddc(c.absPath, principalEmail) werr = WriteAutoOwnZddc(c.absPath, principalEmail, c.roles)
} }
if werr != nil { if werr != nil {
return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, werr) return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, werr)

View file

@ -125,9 +125,9 @@ type ConvertMetadata struct {
// place = URL-fetched apps refused (only embedded + local-path apps // place = URL-fetched apps refused (only embedded + local-path apps
// work). See zddc-server's setupApps. // work). See zddc-server's setupApps.
type ZddcFile struct { type ZddcFile struct {
ACL ACLRules `yaml:"acl" json:"acl"` ACL ACLRules `yaml:"acl,omitempty" json:"acl,omitempty"`
Admins []string `yaml:"admins" json:"admins,omitempty"` Admins []string `yaml:"admins,omitempty" json:"admins,omitempty"`
Title string `yaml:"title" json:"title,omitempty"` Title string `yaml:"title,omitempty" json:"title,omitempty"`
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"` Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"` AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`
@ -231,6 +231,23 @@ type ZddcFile struct {
// admin grants still apply. // admin grants still apply.
AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"` AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"`
// AutoOwnRoles augments AutoOwn with role-level grants: when set,
// the auto-own .zddc written at a new child directory grants each
// listed role `rwcda` ALONGSIDE the creator email. Lets the schema
// express "the creator owns it AND any member of these roles has
// full authority" without resorting to a separate admins: list
// (which would be subtree-admin and bypass WORM / fences via
// elevation — too strong for typical workflows).
//
// Example: archive/<party>/ sets `auto_own_roles: [document_controller]`
// so any DC has rwcda at every party folder a peer created, not
// just at parties they personally mkdir'd.
//
// Grants are written as plain permissions in the new .zddc — they
// have no special semantic beyond what `rwcda` already means in
// the cascade. A fence (auto_own_fenced) still binds them.
AutoOwnRoles []string `yaml:"auto_own_roles,omitempty" json:"auto_own_roles,omitempty"`
// Virtual marks a directory as never-materialise-on-disk. The // Virtual marks a directory as never-materialise-on-disk. The
// server treats requests under such a path as virtual routes // server treats requests under such a path as virtual routes
// rather than triggering EnsureCanonicalAncestors. The reviewing // rather than triggering EnsureCanonicalAncestors. The reviewing

View file

@ -75,6 +75,23 @@ func AutoOwnAt(fsRoot, dirPath string) bool {
return false return false
} }
// AutoOwnRolesAt returns the role names that should be granted rwcda
// in the auto-own .zddc at this dir (alongside the creator's email).
// Leaf-only, same semantic as AutoOwnAt / AutoOwnFencedAt. Empty/nil
// when the cascade declares no role grants — the legacy creator-only
// behavior. Caller passes the result to WriteAutoOwnZddc / Fenced.
func AutoOwnRolesAt(fsRoot, dirPath string) []string {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return nil
}
leaf := leafLevel(chain)
if leaf.AutoOwnRoles != nil {
return leaf.AutoOwnRoles
}
return chain.Embedded.AutoOwnRoles
}
// AutoOwnFencedAt reports whether the auto-own .zddc at this dir // AutoOwnFencedAt reports whether the auto-own .zddc at this dir
// should be written with `inherit: false` (private to creator). // should be written with `inherit: false` (private to creator).
// Leaf-only, same semantic as AutoOwnAt. // Leaf-only, same semantic as AutoOwnAt.

View file

@ -11,19 +11,24 @@ import (
// IsDeclaredPath, ChildrenDeclaredAt, AvailableToolsAt). // IsDeclaredPath, ChildrenDeclaredAt, AvailableToolsAt).
// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting // WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
// principalEmail rwcda and recording it in CreatedBy. Used by the file // principalEmail rwcda and recording it in CreatedBy. Each role name
// API's mkdir post-hook (and by EnsureCanonicalAncestors) to seed // in roles also receives rwcda — gives the schema a way to declare
// ownership when a new auto-own folder is materialised. // "this folder is creator-owned AND any member of these roles has full
// authority" without using subtree-admin (which would bypass WORM /
// fences via elevation). Used by the file API's mkdir post-hook (and
// by EnsureCanonicalAncestors) to seed ownership when a new auto-own
// folder is materialised. Pass nil/empty roles for the legacy
// creator-only behavior.
// //
// The grant is identical to what an operator would write by hand — // The grants are identical to what an operator would write by hand —
// direct email pattern, "rwcda" verb set — so the creator can later // direct email pattern + bare role names, "rwcda" verb set — so the
// edit the file normally to add collaborators. // creator can later edit the file normally to narrow or extend them.
// //
// Atomic: marshals via the same yaml encoder ParseFile reads // Atomic: marshals via the same yaml encoder ParseFile reads
// (round-trip guaranteed) and writes via zddc.WriteFile (which // (round-trip guaranteed) and writes via zddc.WriteFile (which
// performs an atomic temp-write + rename via zddc.WriteAtomic). // performs an atomic temp-write + rename via zddc.WriteAtomic).
func WriteAutoOwnZddc(dir, principalEmail string) error { func WriteAutoOwnZddc(dir, principalEmail string, roles []string) error {
return writeAutoOwn(dir, principalEmail, false) return writeAutoOwn(dir, principalEmail, false, roles)
} }
// WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally // WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally
@ -34,14 +39,26 @@ func WriteAutoOwnZddc(dir, principalEmail string) error {
// Without the fence, an ancestor `*: r` (e.g. a project-root grant for // Without the fence, an ancestor `*: r` (e.g. a project-root grant for
// authenticated users) would let any user read every other user's // authenticated users) would let any user read every other user's
// working subfolder via cascade — defeating the per-user sandbox. // working subfolder via cascade — defeating the per-user sandbox.
func WriteAutoOwnZddcFenced(dir, principalEmail string) error { //
return writeAutoOwn(dir, principalEmail, true) // roles is the same as for WriteAutoOwnZddc — listed roles get rwcda
// alongside the creator, and like the creator grant they're INSIDE
// the fence (only resolvable if the role is defined at this level or
// in chain.Embedded, since ancestor role definitions are hidden by
// inherit:false). Typically callers using the fenced variant pass nil
// roles — per-user homes don't need peer authority.
func WriteAutoOwnZddcFenced(dir, principalEmail string, roles []string) error {
return writeAutoOwn(dir, principalEmail, true, roles)
} }
func writeAutoOwn(dir, principalEmail string, fenced bool) error { func writeAutoOwn(dir, principalEmail string, fenced bool, roles []string) error {
rules := ACLRules{ perms := map[string]string{principalEmail: "rwcda"}
Permissions: map[string]string{principalEmail: "rwcda"}, for _, role := range roles {
if role == "" || role == principalEmail {
continue // skip empty / collision with the creator entry
} }
perms[role] = "rwcda"
}
rules := ACLRules{Permissions: perms}
if fenced { if fenced {
f := false f := false
rules.Inherit = &f rules.Inherit = &f

View file

@ -6,29 +6,53 @@ import (
"testing" "testing"
) )
// TestStandardRoles_DocControllerScopedCreate — with document_controller // TestStandardRoles_DocControllerScopedCreate — DC authority comes
// populated at the on-disk root, the role gets: // PURELY from the cascade now (no subtree-admin / admins: list). The
// - rw at the project level (read + overwrite-existing), but NOT c // model:
// (so it can't make arbitrary folders) // - rw at the project level (read + overwrite-existing, no `c`)
// - rwc at archive/ (can create party subfolders) // - rwc at archive/ (can create party subfolders)
// - subtree-admin at archive/<party>/ (full create + manage; lifecycle // - When DC mkdirs archive/<party>/, ensure.go writes an auto-own
// slots under the party inherit the admin grant) // .zddc granting both the creator email AND the document_controller
// - inside received/issued (WORM): masked to r + worm-restored c // role rwcda there (via auto_own_roles in defaults). This test
// // simulates that .zddc directly so the cascade behaviour can be
// Layout reshape: working/staging/reviewing moved from project root // asserted in isolation.
// into archive/<party>/, so the subtree-admin scope likewise moved // - From the party's auto-own .zddc, the role rwcda cascades down to
// from project-level "working/staging/" to the per-party folder. // descendants by default; explicit slot grants (rwcd at incoming/
// and staging/) shadow it where the workflow needs `d`.
// - At received/issued (WORM): the WORM mask strips w/d/a from the
// inherited rwcda; the worm: list restores c → effective cr.
// - NOT subtree-admin anywhere — no admins: entries for the role.
func TestStandardRoles_DocControllerScopedCreate(t *testing.T) { func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() root := t.TempDir()
// Deployment populates the standard roles. Roles UNION with the // DCs are typically internal employees and ARE in project_team
// embedded (empty) definitions, so this is the effective member set. // (which is commonly defined as the *@example.com wildcard). The
// embedded defaults restate document_controller:rwcda at every
// slot that grants project_team a narrower verb set; the
// cascade's within-level union then gives the DC the higher
// grant. This fixture mirrors the realistic deployment shape so
// the union behavior is actually exercised.
writeZddc(t, root, `roles: writeZddc(t, root, `roles:
document_controller: document_controller:
members: ["dc@example.com"] members: ["dc@example.com"]
project_team: project_team:
members: ["*@example.com"] members: ["*@example.com"]
`) `)
// Simulate the auto-own .zddc the file API writes when DC mkdir's
// archive/Acme/. Carries the creator email + the document_controller
// role per the embedded defaults' auto_own_roles entry.
partyDir := filepath.Join(root, "Proj", "archive", "Acme")
if err := os.MkdirAll(partyDir, 0o755); err != nil {
t.Fatal(err)
}
writeZddc(t, partyDir, `acl:
permissions:
"dc@example.com": rwcda
document_controller: rwcda
created_by: dc@example.com
`)
resetCache()
dc := "dc@example.com" dc := "dc@example.com"
mustVerbs := func(dir string, want string) { mustVerbs := func(dir string, want string) {
@ -37,7 +61,6 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("EffectivePolicy(%q): %v", dir, err) t.Fatalf("EffectivePolicy(%q): %v", dir, err)
} }
// Mirror InternalDecider.Allow's WORM-aware composition.
var got VerbSet var got VerbSet
if g, inWorm := WormZoneGrant(chain, dc); inWorm { if g, inWorm := WormZoneGrant(chain, dc); inWorm {
got = (EffectiveVerbs(chain, dc) & VerbR) | (g & VerbsRC) got = (EffectiveVerbs(chain, dc) & VerbR) | (g & VerbsRC)
@ -55,36 +78,94 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
mustVerbs(filepath.Join(root, "Proj", "random-folder"), "rw") mustVerbs(filepath.Join(root, "Proj", "random-folder"), "rw")
// archive/: rwc (can create party folders). // archive/: rwc (can create party folders).
mustVerbs(filepath.Join(root, "Proj", "archive"), "rwc") mustVerbs(filepath.Join(root, "Proj", "archive"), "rwc")
// incoming/: rwcd — the QC + transfer-out workflow needs delete. // At the party folder itself: rwcda via the auto-own role grant.
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "incoming"), "rwcd") mustVerbs(partyDir, "rwcda")
// received/ (WORM): rw masked to r, plus worm-restored c → "rc". // Lifecycle slots inside the party folder inherit rwcda from the
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "rc") // party-level role grant where no slot-local grant overrides.
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "rc") mustVerbs(filepath.Join(partyDir, "working"), "rwcda")
mustVerbs(filepath.Join(partyDir, "reviewing"), "rwcda")
// incoming/ has explicit document_controller: rwcd
// — leaf-wins shadows the rwcda inherited from <party>/.
mustVerbs(filepath.Join(partyDir, "incoming"), "rwcd")
// staging/ has explicit document_controller: rwcda (rwcd for
// transfer + `a` for Plan Review's staging/<tracking>/.zddc).
mustVerbs(filepath.Join(partyDir, "staging"), "rwcda")
// received/ (WORM): inherited rwcda masked to r + worm-restored c.
mustVerbs(filepath.Join(partyDir, "received"), "rc")
mustVerbs(filepath.Join(partyDir, "issued"), "rc")
// Subtree-admin at archive/<party>/ (the embedded cascade // NOT subtree-admin anywhere — even when notionally elevated,
// declares admins: [document_controller] on the party "*" entry, // the role carries no admin: grant.
// so working/staging/reviewing inside the party inherit it). for _, p := range []string{
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme"), Principal{Email: dc, Elevated: true}) { filepath.Join(root, "Proj"),
t.Errorf("doc controller should be subtree-admin of archive/<party>/") filepath.Join(root, "Proj", "archive"),
partyDir,
filepath.Join(partyDir, "working"),
filepath.Join(partyDir, "staging"),
filepath.Join(partyDir, "reviewing"),
filepath.Join(partyDir, "received"),
filepath.Join(partyDir, "issued"),
} {
if IsSubtreeAdmin(root, p, Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should NOT be subtree-admin of %s (no admins: list anywhere)", p[len(root):])
} }
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should be subtree-admin of archive/<party>/working/")
} }
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "staging"), Principal{Email: dc, Elevated: true}) { // And specifically — they CAN'T reach inside a fenced per-user
t.Errorf("doc controller should be subtree-admin of archive/<party>/staging/") // working home. The fence isolates team-member workspaces from
// every other role (including DC) by design.
homeDir := filepath.Join(partyDir, "working", "alice@example.com")
if err := os.MkdirAll(homeDir, 0o755); err != nil {
t.Fatal(err)
} }
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "reviewing"), Principal{Email: dc, Elevated: true}) { writeZddc(t, homeDir, `acl:
t.Errorf("doc controller should be subtree-admin of archive/<party>/reviewing/") inherit: false
permissions:
"alice@example.com": rwcda
created_by: alice@example.com
`)
resetCache()
chain, _ := EffectivePolicy(root, homeDir)
if got := EffectiveVerbs(chain, dc); got != 0 {
t.Errorf("doc controller inside alice's fenced home = %q, want empty (fence isolates)", got.String())
} }
// NOT subtree-admin of archive/ (so WORM still binds them at the }
// received/issued slots below).
if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), Principal{Email: dc, Elevated: true}) { // TestStandardRoles_DocControllerMultiDC — a second DC added to the
t.Errorf("doc controller should NOT be subtree-admin of archive/ (only of each party folder)") // role gets the SAME rwcda at every party that any DC created,
// because the auto-own .zddc grants the role (not just the creator's
// email) via auto_own_roles in defaults.
func TestStandardRoles_DocControllerMultiDC(t *testing.T) {
resetCache()
root := t.TempDir()
writeZddc(t, root, `roles:
document_controller:
members: ["dc1@example.com", "dc2@example.com"]
`)
// dc1 created the party folder; the auto-own .zddc lists both
// dc1 (creator email) and the document_controller role (from
// auto_own_roles in defaults).
partyDir := filepath.Join(root, "Proj", "archive", "Acme")
if err := os.MkdirAll(partyDir, 0o755); err != nil {
t.Fatal(err)
} }
// Subtree-admin reaches inside a fenced per-user working home writeZddc(t, partyDir, `acl:
// under the party's working slot. permissions:
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), Principal{Email: dc, Elevated: true}) { "dc1@example.com": rwcda
t.Errorf("doc controller (subtree-admin of party) should reach inside a fenced user home") document_controller: rwcda
created_by: dc1@example.com
`)
resetCache()
chain, _ := EffectivePolicy(root, partyDir)
// dc1 (creator) has rwcda directly.
if got := EffectiveVerbs(chain, "dc1@example.com"); got.String() != "rwcda" {
t.Errorf("dc1 (creator) at party = %q, want rwcda", got.String())
}
// dc2 (non-creator) ALSO has rwcda via the role grant. This is
// the whole point of auto_own_roles — peer DCs share authority
// without admin status.
if got := EffectiveVerbs(chain, "dc2@example.com"); got.String() != "rwcda" {
t.Errorf("dc2 (peer) at party = %q, want rwcda (role grant from auto_own_roles)", got.String())
} }
} }
@ -141,6 +222,73 @@ created_by: alice@example.com
} }
} }
// TestStandardRoles_ProjectTeamInFlightRatchet — the one-way handoff
// from working/ → staging/ → issued/ as the team member sees it:
// full work in working (cr at the slot + rwcda inside the fenced
// home), drop-only in staging (cr — no modify after the drop), drop
// inside auto-own iteration folder in reviewing (cr at the slot,
// rwcda inside the auto-owned sub-folder), read-only in received/
// issued (WORM zones) and incoming/ (counterparty drop zone).
func TestStandardRoles_ProjectTeamInFlightRatchet(t *testing.T) {
resetCache()
root := t.TempDir()
writeZddc(t, root, `roles:
project_team:
members: ["*@example.com"]
`)
alice := "alice@example.com"
mustVerbs := func(dir string, want string) {
t.Helper()
chain, err := EffectivePolicy(root, dir)
if err != nil {
t.Fatalf("EffectivePolicy(%q): %v", dir, err)
}
var got VerbSet
if g, inWorm := WormZoneGrant(chain, alice); inWorm {
got = (EffectiveVerbs(chain, alice) & VerbR) | (g & VerbsRC)
} else {
got = EffectiveVerbs(chain, alice)
}
if got.String() != want {
t.Errorf("project_team alice at %s = %q, want %q", dir[len(root):], got.String(), want)
}
}
party := filepath.Join(root, "Proj", "archive", "Acme")
mustVerbs(filepath.Join(party, "working"), "rc") // create + read at slot
mustVerbs(filepath.Join(party, "staging"), "rc") // drop + read, no modify
mustVerbs(filepath.Join(party, "reviewing"), "rc") // create iteration folders
mustVerbs(filepath.Join(party, "received"), "r") // WORM — read pass-through, no worm-create
mustVerbs(filepath.Join(party, "issued"), "r") // WORM — same
mustVerbs(filepath.Join(party, "incoming"), "r") // counterparty drop zone — read only
}
// TestStandardRoles_DocControllerStagingDelete — DC needs `d` at
// staging/ to perform the staging-to-issued transfer (cut, not copy);
// the explicit document_controller: rwcd grant supplies it. Mirrors
// the incoming/ pattern (line 286-288 of defaults.zddc.yaml) where
// the QC + transfer-out workflow needed the same.
func TestStandardRoles_DocControllerStagingDelete(t *testing.T) {
resetCache()
root := t.TempDir()
writeZddc(t, root, `roles:
document_controller:
members: ["dc@example.com"]
`)
dc := "dc@example.com"
chain, err := EffectivePolicy(root, filepath.Join(root, "Proj", "archive", "Acme", "staging"))
if err != nil {
t.Fatalf("EffectivePolicy: %v", err)
}
got := EffectiveVerbs(chain, dc)
for _, v := range []VerbSet{VerbR, VerbW, VerbC, VerbD} {
if !got.Has(v) {
t.Errorf("doc controller at staging/ missing verb %q in %q (need rwcd for transfer-to-issued)", v.String(), got.String())
}
}
}
// TestStandardRoles_ObserverReadOnlyEverywhere — observer is the // TestStandardRoles_ObserverReadOnlyEverywhere — observer is the
// project-wide read-only role for auditors / regulators / external // project-wide read-only role for auditors / regulators / external
// viewers. Unlike project_team, an observer must not contribute // viewers. Unlike project_team, an observer must not contribute

View file

@ -78,6 +78,13 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
if top.AutoOwnFenced != nil { if top.AutoOwnFenced != nil {
out.AutoOwnFenced = top.AutoOwnFenced out.AutoOwnFenced = top.AutoOwnFenced
} }
// AutoOwnRoles: presence (non-nil) overrides; a deeper level
// declaring an empty list replaces (and explicitly suppresses)
// the ancestor's role list. This matches the leaf-wins semantic
// for the other path-tree contribution lists.
if top.AutoOwnRoles != nil {
out.AutoOwnRoles = top.AutoOwnRoles
}
if top.DropTarget != nil { if top.DropTarget != nil {
out.DropTarget = top.DropTarget out.DropTarget = top.DropTarget
} }