Compare commits
7 commits
360049f482
...
b4d59b11ee
| Author | SHA1 | Date | |
|---|---|---|---|
| b4d59b11ee | |||
| 90a31020db | |||
| 736f422f82 | |||
| ba98b87b2a | |||
| b5a725e745 | |||
| a0a3f8579b | |||
| 43c2879e9c |
26 changed files with 2483 additions and 280 deletions
24
AGENTS.md
24
AGENTS.md
|
|
@ -494,14 +494,22 @@ roles:
|
|||
- 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:
|
||||
|
||||
- `document_controller` — per-party records custodian; files into WORM `received/issued`, manages the `working/staging/reviewing` lifecycle, QCs the counterparty's drops in `incoming/`.
|
||||
- `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`).
|
||||
- `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. 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.
|
||||
|
||||
**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`):
|
||||
|
||||
- `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 `@`).
|
||||
- `roles: { <name>: { members: [...], reset: <bool>? } }` — members union across the cascade unless `reset: true`.
|
||||
- `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.
|
||||
|
||||
**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.).
|
||||
|
||||
### Build
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
|
|
|
|||
|
|
@ -72,15 +72,27 @@
|
|||
// clicked a header link, the URL bar, etc. without moving to
|
||||
// another row first). focusout fires for cell-to-cell moves
|
||||
// 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');
|
||||
if (tableRoot) {
|
||||
tableRoot.addEventListener('focusout', function (ev) {
|
||||
const next = ev.relatedTarget;
|
||||
if (next && tableRoot.contains(next)) return;
|
||||
const save = app.modules.save;
|
||||
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
|
||||
save.flushAll();
|
||||
}
|
||||
setTimeout(function () {
|
||||
if (tableRoot.contains(document.activeElement)) return;
|
||||
const save = app.modules.save;
|
||||
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
|
||||
save.flushAll();
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -149,30 +149,42 @@ test.describe('Browse', () => {
|
|||
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.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__setMockDirectoryTree('mock-folder', {
|
||||
'a.txt': 'AAA',
|
||||
'sub': { 'b.txt': 'BBB', 'deep': { 'c.txt': 'CCC' } },
|
||||
'.zddc': 'acl: { permissions: { "*": r } }', // hidden — must not be in the zip
|
||||
'_template': { 'scaffold.txt': 'x' }, // hidden dir — must not be in the zip
|
||||
'sub': {
|
||||
'b.txt': 'BBB',
|
||||
'deep': { 'c.txt': 'CCC' },
|
||||
'.zddc': 'acl: { permissions: { "*": r } }', // hidden — 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.waitForSelector('#browseRoot:not(.hidden)', { timeout: 10000 });
|
||||
|
||||
// The Download (zip) button appears once a directory is loaded.
|
||||
const dlBtn = page.locator('#downloadZipBtn');
|
||||
await expect(dlBtn).toBeVisible();
|
||||
|
||||
// Download ZIP lives in the row's right-click context menu —
|
||||
// the standalone toolbar button was retired when the context
|
||||
// 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([
|
||||
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 buf = await fs.readFile(file);
|
||||
|
|
@ -190,9 +202,8 @@ test.describe('Browse', () => {
|
|||
return Object.keys(z.files).filter((n) => !z.files[n].dir).sort();
|
||||
}, b64);
|
||||
expect(entries).toEqual([
|
||||
'mock-folder/a.txt',
|
||||
'mock-folder/sub/b.txt',
|
||||
'mock-folder/sub/deep/c.txt',
|
||||
'sub/b.txt',
|
||||
'sub/deep/c.txt',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,15 +45,16 @@ for (const tool of tools) {
|
|||
const match = html.match(/class="build-timestamp">(?:<span[^>]*>)?([^<]+?)(?:<\/span>)?</);
|
||||
expect(match, 'build-timestamp element must have text content').toBeTruthy();
|
||||
const label = match[1];
|
||||
// Plain dev builds and --release alpha|beta share one label
|
||||
// shape — full UTC timestamp + short source SHA (with
|
||||
// optional -dirty marker on plain dev when the tree is
|
||||
// uncommitted):
|
||||
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6"
|
||||
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6-dirty"
|
||||
// Plain dev builds, ./build beta, and the retired
|
||||
// ./build alpha all share one label shape — full UTC
|
||||
// timestamp + short source SHA (with optional -dirty
|
||||
// marker when the tree is uncommitted):
|
||||
// "v0.0.21-dev · 2026-05-21 16:11:12 · 736f422"
|
||||
// "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-alpha · 2026-04-29 00:50:17 · 714faf6"
|
||||
// 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);
|
||||
expect(isChannel || isVersion,
|
||||
`Expected channel or version label, got: "${label}"`
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{ name: '176109', url: '/176109/', title: 'Greenfield Substation' },
|
||||
{ name: '197072', url: '/197072/', title: 'Brownfield Tap' },
|
||||
{ name: '210045', url: '/210045/', title: '' },
|
||||
{ name: '176109/', is_dir: true, url: '/176109/', title: 'Greenfield Substation' },
|
||||
{ name: '197072/', is_dir: true, url: '/197072/', title: 'Brownfield Tap' },
|
||||
{ name: '210045/', is_dir: true, url: '/210045/', title: '' },
|
||||
];
|
||||
|
||||
async function loadLandingWithProjects(page, projects) {
|
||||
|
|
|
|||
|
|
@ -836,6 +836,25 @@ body.help-open .app-header {
|
|||
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.
|
||||
Renders only for users with admin scope (handled by elevation.js;
|
||||
the placeholder is `.hidden` by default). When visible, sits left
|
||||
|
|
@ -2563,7 +2582,7 @@ td[data-field="trackingNumber"] {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</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 };
|
||||
})();
|
||||
|
||||
// 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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -836,6 +836,25 @@ body.help-open .app-header {
|
|||
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
|
||||
inherits the logo's box and adds a subtle hover/focus affordance
|
||||
so it reads as clickable without altering the logo's visual weight. */
|
||||
|
|
@ -2350,7 +2369,7 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -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
|
||||
// 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.
|
||||
// `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, ... }
|
||||
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
|
||||
// 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
|
||||
// conditionally without managing dividers.
|
||||
//
|
||||
// Any of `label`, `checked`, `visible`, `disabled`, and `items` may
|
||||
// be a function — each is invoked with the context object so callers
|
||||
// can render fully context-aware menus from a single declarative
|
||||
// config.
|
||||
// Any of `label`, `checked`, `visible`, `disabled`, `tooltip`, and
|
||||
// `items` may be a function — each is invoked with the context object
|
||||
// so callers can render fully context-aware menus from a single
|
||||
// declarative config.
|
||||
//
|
||||
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
|
||||
// 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.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
if ('tooltip' in item) {
|
||||
var tip = resolve(item.tooltip, ctx);
|
||||
if (tip) row.title = String(tip);
|
||||
}
|
||||
row.setAttribute('role',
|
||||
hasSub ? '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 };
|
||||
})();
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// decider would allow a PUT for the calling principal.
|
||||
// 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,
|
||||
// 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):
|
||||
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
|
||||
// silently makes every node read-only — the actual root
|
||||
// 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);
|
||||
return node;
|
||||
|
|
@ -8125,11 +8342,14 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
|
||||
function canSave(node) {
|
||||
if (isZipMemberNode(node)) return false;
|
||||
// Server-computed authority gate. The listing's `writable`
|
||||
// bit reflects what a PUT would do — false here means the
|
||||
// file API would 403 the save, so we mount in read-only
|
||||
// mode rather than letting the user type and lose changes.
|
||||
if (node.url && window.app.state.source === 'server' && !node.writable) return false;
|
||||
// Server-computed authority gate. The listing's verbs string
|
||||
// tells us whether a PUT to this entry would be allowed —
|
||||
// false here means the file API would 403, so we mount in
|
||||
// read-only mode rather than letting the user type and lose
|
||||
// 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.url && window.app.state.source === 'server') return true;
|
||||
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
|
||||
// affordance, not a writable file.
|
||||
if (node.virtual && node.name !== '.zddc') return false;
|
||||
// Server-computed authority gate. Mirrors the markdown editor's
|
||||
// check — listing's `writable` bit is the same decision the
|
||||
// file API would reach on PUT.
|
||||
if (node.url && window.app.state.source === 'server' && !node.writable) return false;
|
||||
// Server-computed authority gate. The virtual .zddc entry
|
||||
// requires the admin verb 'a' (matches fileapi.go's
|
||||
// ActionAdmin gate at the .zddc URL); regular YAML files
|
||||
// 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.url && window.app.state.source === 'server') return true;
|
||||
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: move a file from working/<…>/ into a transmittal folder under
|
||||
// staging/<…>/. Modal lists existing transmittal folders in staging/
|
||||
// plus a "New transmittal folder…" option that prompts for a ZDDC-
|
||||
// conforming name and mkdirs it before the move.
|
||||
// After the layout reshape, working/ and staging/ live INSIDE each
|
||||
// party folder: archive/<party>/working/<email>/<file> and
|
||||
// archive/<party>/staging/<batch>/<file>. Stage and Unstage are now
|
||||
// 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
|
||||
// working/<email>/ home (overridable).
|
||||
// Stage: move a file from archive/<party>/working/<…> into a
|
||||
// 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
|
||||
// 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 ──────────────
|
||||
// A file is stageable if its containing folder lives under
|
||||
// /<project>/working/<…>. Unstageable if it lives under
|
||||
// /<project>/staging/<transmittal>/<…>. Both are path-shape
|
||||
// queries — content/ACL is enforced server-side.
|
||||
// A file is stageable if its path matches
|
||||
// /<project>/archive/<party>/working/<…>. Unstageable if it
|
||||
// matches /<project>/archive/<party>/staging/<transmittal>/<…>.
|
||||
// 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('/');
|
||||
if (rel.length < 2) return null;
|
||||
return { project: rel[0], subtree: rel[1], rest: rel.slice(2) };
|
||||
if (rel.length < 4) return null;
|
||||
if (rel[1].toLowerCase() !== 'archive') return null;
|
||||
return { project: rel[0], party: rel[2], slot: rel[3], rest: rel.slice(4) };
|
||||
}
|
||||
|
||||
function isStageableFile(node) {
|
||||
if (!node || node.isDir || node.virtual) return false;
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return false;
|
||||
var p = projectAndSubtree(tree.pathFor(node));
|
||||
return !!(p && p.subtree === 'working' && p.rest.length >= 1);
|
||||
var p = projectPartySlot(tree.pathFor(node));
|
||||
return !!(p && p.slot === 'working' && p.rest.length >= 1);
|
||||
}
|
||||
function isUnstageableFile(node) {
|
||||
if (!node || node.isDir || node.virtual) return false;
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return false;
|
||||
var p = projectAndSubtree(tree.pathFor(node));
|
||||
// staging/<transmittal-folder>/<file> — at least one folder
|
||||
// segment between staging/ and the file.
|
||||
return !!(p && p.subtree === 'staging' && p.rest.length >= 2);
|
||||
var p = projectPartySlot(tree.pathFor(node));
|
||||
// archive/<party>/staging/<transmittal-folder>/<file> — at
|
||||
// least one folder segment between staging/ and the file.
|
||||
return !!(p && p.slot === 'staging' && p.rest.length >= 2);
|
||||
}
|
||||
|
||||
// ── Server helpers ─────────────────────────────────────────────────
|
||||
|
|
@ -10903,8 +11142,9 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async function fetchStagingFolders(project) {
|
||||
var entries = await listDir('/' + project + '/staging/');
|
||||
async function fetchStagingFolders(project, party) {
|
||||
var entries = await listDir(
|
||||
'/' + project + '/archive/' + encodeURIComponent(party) + '/staging/');
|
||||
return entries
|
||||
.filter(function (e) { return e && e.isDir; })
|
||||
.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;
|
||||
if (!tree) return;
|
||||
var srcUrl = tree.pathFor(node);
|
||||
var info = projectAndSubtree(srcUrl);
|
||||
if (!info || info.subtree !== 'working') {
|
||||
status('Stage applies only to files under working/.', 'error');
|
||||
var info = projectPartySlot(srcUrl);
|
||||
if (!info || info.slot !== 'working') {
|
||||
status('Stage applies only to files under archive/<party>/working/.', 'error');
|
||||
return;
|
||||
}
|
||||
var stagingBase = '/' + info.project + '/staging/';
|
||||
var stagingBase = '/' + info.project + '/archive/' +
|
||||
encodeURIComponent(info.party) + '/staging/';
|
||||
var folders;
|
||||
try { folders = await fetchStagingFolders(info.project); }
|
||||
try { folders = await fetchStagingFolders(info.project, info.party); }
|
||||
catch (e) {
|
||||
status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error');
|
||||
return;
|
||||
|
|
@ -11124,20 +11365,21 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
status((e && e.message) || 'move failed', 'error');
|
||||
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) {
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return;
|
||||
var srcUrl = tree.pathFor(node);
|
||||
var info = projectAndSubtree(srcUrl);
|
||||
if (!info || info.subtree !== 'staging') {
|
||||
status('Unstage applies only to files under staging/.', 'error');
|
||||
var info = projectPartySlot(srcUrl);
|
||||
if (!info || info.slot !== 'staging') {
|
||||
status('Unstage applies only to files under archive/<party>/staging/.', 'error');
|
||||
return;
|
||||
}
|
||||
var email = await fetchSelfEmail();
|
||||
var defaultTarget = '/' + info.project + '/working/' + (email || '') + '/';
|
||||
var defaultTarget = '/' + info.project + '/archive/' +
|
||||
encodeURIComponent(info.party) + '/working/' + (email || '') + '/';
|
||||
var choice;
|
||||
try {
|
||||
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 },
|
||||
|
||||
// ── 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…',
|
||||
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); }
|
||||
},
|
||||
{
|
||||
label: 'Delete…',
|
||||
icon: '🗑',
|
||||
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); }
|
||||
},
|
||||
{ separator: true },
|
||||
|
|
|
|||
|
|
@ -836,6 +836,25 @@ body.help-open .app-header {
|
|||
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.
|
||||
Renders only for users with admin scope (handled by elevation.js;
|
||||
the placeholder is `.hidden` by default). When visible, sits left
|
||||
|
|
@ -1774,7 +1793,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -9994,6 +10013,170 @@ X.B(E,Y);return E}return J}())
|
|||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -836,6 +836,25 @@ body.help-open .app-header {
|
|||
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.
|
||||
Renders only for users with admin scope (handled by elevation.js;
|
||||
the placeholder is `.hidden` by default). When visible, sits left
|
||||
|
|
@ -1517,7 +1536,7 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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 class="header-right">
|
||||
|
|
@ -2676,6 +2695,170 @@ body {
|
|||
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() {
|
||||
'use strict';
|
||||
// ZDDC landing page — project picker.
|
||||
|
|
|
|||
|
|
@ -840,6 +840,25 @@ body.help-open .app-header {
|
|||
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.
|
||||
Renders only for users with admin scope (handled by elevation.js;
|
||||
the placeholder is `.hidden` by default). When visible, sits left
|
||||
|
|
@ -2616,7 +2635,7 @@ dialog.modal--narrow {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- 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 };
|
||||
})();
|
||||
|
||||
// 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) {
|
||||
'use strict';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||
archive=v0.0.19
|
||||
transmittal=v0.0.19
|
||||
classifier=v0.0.19
|
||||
landing=v0.0.19
|
||||
form=v0.0.19
|
||||
tables=v0.0.19
|
||||
browse=v0.0.19
|
||||
archive=v0.0.21
|
||||
transmittal=v0.0.21
|
||||
classifier=v0.0.21
|
||||
landing=v0.0.21
|
||||
form=v0.0.21
|
||||
tables=v0.0.21
|
||||
browse=v0.0.21
|
||||
|
|
|
|||
|
|
@ -758,11 +758,12 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
// unfenced so ancestor grants still cascade through.
|
||||
if email != "" {
|
||||
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
|
||||
roles := zddc.AutoOwnRolesAt(cfg.Root, abs)
|
||||
var werr error
|
||||
if zddc.AutoOwnFencedAt(cfg.Root, abs) {
|
||||
werr = zddc.WriteAutoOwnZddcFenced(abs, email)
|
||||
werr = zddc.WriteAutoOwnZddcFenced(abs, email, roles)
|
||||
} else {
|
||||
werr = zddc.WriteAutoOwnZddc(abs, email)
|
||||
werr = zddc.WriteAutoOwnZddc(abs, email, roles)
|
||||
}
|
||||
if werr != nil {
|
||||
slog.Warn("auto-own .zddc write failed", "path", abs, "err", werr)
|
||||
|
|
|
|||
|
|
@ -23,11 +23,16 @@ func planReviewSetup(t *testing.T) (config.Config, func(target, email string, bo
|
|||
t.Helper()
|
||||
root := t.TempDir()
|
||||
|
||||
// Root .zddc grants alice subtree-admin everywhere AND sets the
|
||||
// document_controller role so the cascade's reviewing/+staging/
|
||||
// admin grants resolve to her. The role membership also confers
|
||||
// `c` authority on received/ via the WORM list in the defaults,
|
||||
// which Plan Review's pre-flight requires.
|
||||
// Root .zddc grants alice root-admin AND adds her to the
|
||||
// document_controller role. The root-admin status + elevated
|
||||
// principal (set on the request below) is what carries her past
|
||||
// Plan Review's ActionAdmin checks — DCs are no longer subtree-
|
||||
// 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"),
|
||||
"admins:\n - alice@example.com\n"+
|
||||
"roles:\n document_controller:\n members: [alice@example.com]\n")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// fires here exactly as it would for a manual mkdir.
|
||||
if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) {
|
||||
roles := zddc.AutoOwnRolesAt(cfg.Root, partyAbs)
|
||||
var werr error
|
||||
if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) {
|
||||
werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email)
|
||||
werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email, roles)
|
||||
} else {
|
||||
werr = zddc.WriteAutoOwnZddc(partyAbs, email)
|
||||
werr = zddc.WriteAutoOwnZddc(partyAbs, email, roles)
|
||||
}
|
||||
if werr != nil {
|
||||
slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr)
|
||||
|
|
|
|||
|
|
@ -836,6 +836,25 @@ body.help-open .app-header {
|
|||
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.
|
||||
Renders only for users with admin scope (handled by elevation.js;
|
||||
the placeholder is `.hidden` by default). When visible, sits left
|
||||
|
|
@ -1515,7 +1534,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.20-dev · 2026-05-20 20:09:54 · 703449a-dirty</span></span>
|
||||
<span class="build-timestamp">v0.0.21</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -2952,6 +2971,170 @@ body.is-elevated::after {
|
|||
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
|
||||
// window.zddc.menu. Built so every ZDDC tool can drop a right-click
|
||||
// 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
|
||||
// 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.
|
||||
// `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, ... }
|
||||
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
|
||||
// a ✓ in the gutter when truthy.
|
||||
|
|
@ -2975,10 +3161,10 @@ body.is-elevated::after {
|
|||
// are collapsed automatically so callers can build items
|
||||
// conditionally without managing dividers.
|
||||
//
|
||||
// Any of `label`, `checked`, `visible`, `disabled`, and `items` may
|
||||
// be a function — each is invoked with the context object so callers
|
||||
// can render fully context-aware menus from a single declarative
|
||||
// config.
|
||||
// Any of `label`, `checked`, `visible`, `disabled`, `tooltip`, and
|
||||
// `items` may be a function — each is invoked with the context object
|
||||
// so callers can render fully context-aware menus from a single
|
||||
// declarative config.
|
||||
//
|
||||
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
|
||||
// submenu, ArrowLeft / Escape backs up one level (or closes if
|
||||
|
|
@ -3100,6 +3286,10 @@ body.is-elevated::after {
|
|||
row.classList.add('is-disabled');
|
||||
row.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
if ('tooltip' in item) {
|
||||
var tip = resolve(item.tooltip, ctx);
|
||||
if (tip) row.title = String(tip);
|
||||
}
|
||||
row.setAttribute('role',
|
||||
hasSub ? 'menuitem'
|
||||
: (isToggle ? 'menuitemcheckbox' : 'menuitem'));
|
||||
|
|
@ -5440,6 +5630,17 @@ body.is-elevated::after {
|
|||
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.
|
||||
console.warn('[tables] save returned', resp.status);
|
||||
setRowState(rowId, 'errored');
|
||||
|
|
@ -5519,6 +5720,17 @@ body.is-elevated::after {
|
|||
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);
|
||||
setRowState(rowId, 'errored');
|
||||
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
|
||||
// another row first). focusout fires for cell-to-cell moves
|
||||
// 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');
|
||||
if (tableRoot) {
|
||||
tableRoot.addEventListener('focusout', function (ev) {
|
||||
const next = ev.relatedTarget;
|
||||
if (next && tableRoot.contains(next)) return;
|
||||
const save = app.modules.save;
|
||||
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
|
||||
save.flushAll();
|
||||
}
|
||||
setTimeout(function () {
|
||||
if (tableRoot.contains(document.activeElement)) return;
|
||||
const save = app.modules.save;
|
||||
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
|
||||
save.flushAll();
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -6527,6 +6751,33 @@ body.is-elevated::after {
|
|||
addRowBtn.addEventListener('keydown', function (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');
|
||||
} else if (res.status === 403) {
|
||||
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) {
|
||||
showStatus('A submission with this filename already exists.', 'error');
|
||||
} else {
|
||||
|
|
@ -7476,6 +7733,29 @@ body.is-elevated::after {
|
|||
const submitBtn = document.getElementById('submit-btn');
|
||||
if (submitBtn) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
"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.
|
||||
// On-disk: if <dir>/.zddc exists, its bytes are returned verbatim
|
||||
// with Content-Type: application/yaml.
|
||||
// Virtual: if it does not exist, a synthetic body is returned with a
|
||||
// cascade summary so the operator can see what rules are
|
||||
// effective at this depth. The synthetic body is clearly
|
||||
// marked with comments — PUT-saving its bytes back to the
|
||||
// same URL (through the file API) materialises a real file.
|
||||
// The virtual response sets X-ZDDC-Source: virtual so the
|
||||
// client can distinguish.
|
||||
// Virtual: if it does not exist, the body is the cascade's
|
||||
// leaf-level ZddcFile (what defaults.zddc.yaml's paths:
|
||||
// tree declares for THIS exact directory, plus any
|
||||
// virtual contributions threaded through by the walker)
|
||||
// marshalled as YAML. A header comment names the source
|
||||
// and points at ?effective=1 for the composed view. The
|
||||
// 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) {
|
||||
decider := DeciderFromContext(r)
|
||||
|
||||
|
|
@ -86,6 +91,16 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
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")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
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
|
||||
}
|
||||
|
||||
// No file on disk → synthetic placeholder body with a cascade
|
||||
// summary so the user can see what's actually effective here.
|
||||
body := renderVirtualZddc(cfg.Root, abs, chain)
|
||||
// No file on disk → render the cascade's leaf level as YAML.
|
||||
// What the user sees is the embedded defaults' declared shape
|
||||
// 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")
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
|
|
@ -113,75 +134,168 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
|
||||
// renderVirtualZddc produces a self-describing YAML placeholder for a
|
||||
// directory that has no .zddc on disk. The body is valid YAML (parses
|
||||
// to an empty document) so a downstream YAML tool isn't fazed; the
|
||||
// commentary lives in comments. Each ancestor's contribution is
|
||||
// summarised so the reader sees exactly what's effective at this
|
||||
// depth.
|
||||
func renderVirtualZddc(fsRoot, dirAbs string, chain zddc.PolicyChain) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Virtual .zddc — no file on disk at this directory yet.\n")
|
||||
fmt.Fprintf(&b, "# Rules below are inherited from ancestors. Edit + save\n")
|
||||
fmt.Fprintf(&b, "# (PUT) through the YAML editor in browse (admin-only)\n")
|
||||
fmt.Fprintf(&b, "# to override at this level — the save materialises a\n")
|
||||
fmt.Fprintf(&b, "# real file here.\n")
|
||||
fmt.Fprintf(&b, "#\n")
|
||||
fmt.Fprintf(&b, "# Effective cascade at %s:\n", urlPathOf(fsRoot, dirAbs))
|
||||
// renderVirtualZddc produces a YAML body for a directory that has no
|
||||
// .zddc on disk. The body is the cascade's leaf-level ZddcFile —
|
||||
// i.e. what defaults.zddc.yaml's paths: tree declares for this exact
|
||||
// directory, plus any contributions the walker threaded through. The
|
||||
// goal is to expose the embedded defaults' source of truth: a new
|
||||
// user opening the virtual .zddc here sees, in the same yaml shape
|
||||
// they would write themselves, what behavior is currently declared
|
||||
// at this level. A header comment names the source and points at
|
||||
// ?effective=1 for the composed view across the chain.
|
||||
//
|
||||
// PUT-saving these bytes back through the file API materialises a
|
||||
// real on-disk override carrying exactly the saved content — the
|
||||
// virtual body is a template, not a contract; the operator can
|
||||
// trim / extend / overwrite freely.
|
||||
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
|
||||
// corresponds to one ancestor (root, .../, ..., dirAbs). Show only
|
||||
// the levels that contributed something non-empty.
|
||||
dirs := chainDirs(fsRoot, dirAbs)
|
||||
any := false
|
||||
for i, lvl := range chain.Levels {
|
||||
var levelDir string
|
||||
if i < len(dirs) {
|
||||
levelDir = dirs[i]
|
||||
} else {
|
||||
levelDir = fsRoot
|
||||
}
|
||||
entry := summariseLevel(lvl)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
any = true
|
||||
fmt.Fprintf(&b, "#\n# from %s/.zddc:\n%s",
|
||||
urlPathOf(fsRoot, levelDir), entry)
|
||||
var b strings.Builder
|
||||
b.WriteString("# Virtual .zddc — no file on disk at this directory.\n")
|
||||
b.WriteString("# The content below is what the embedded defaults\n")
|
||||
b.WriteString("# (defaults.zddc.yaml's paths: tree) declare for this\n")
|
||||
b.WriteString("# exact path. Edit and save through the YAML editor in\n")
|
||||
b.WriteString("# browse to materialise a real .zddc here carrying your\n")
|
||||
b.WriteString("# changes; the bytes you save become the override\n")
|
||||
b.WriteString("# verbatim (no merge, no synthesis — .zddc files drive\n")
|
||||
b.WriteString("# policy and are the single source of truth).\n")
|
||||
b.WriteString("#\n")
|
||||
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
|
||||
}
|
||||
if !any {
|
||||
fmt.Fprintf(&b, "# (no ancestor .zddc contributes any rule)\n")
|
||||
|
||||
body, err := yaml.Marshal(&leaf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(&b, "\n# --- placeholder body (empty) ---\n")
|
||||
fmt.Fprintf(&b, "{}\n")
|
||||
return b.String()
|
||||
b.WriteByte('\n')
|
||||
b.Write(body)
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// summariseLevel produces a comment block describing one .zddc level's
|
||||
// non-empty contributions (title, acl, admins, apps, tables). Empty
|
||||
// levels return "" so the caller can skip them.
|
||||
func summariseLevel(lvl zddc.ZddcFile) string {
|
||||
var b strings.Builder
|
||||
if lvl.Title != "" {
|
||||
fmt.Fprintf(&b, "# title: %q\n", lvl.Title)
|
||||
}
|
||||
if len(lvl.ACL.Permissions) > 0 {
|
||||
fmt.Fprintf(&b, "# acl.permissions: %v\n", lvl.ACL.Permissions)
|
||||
}
|
||||
if len(lvl.Admins) > 0 {
|
||||
fmt.Fprintf(&b, "# admins: %v\n", lvl.Admins)
|
||||
}
|
||||
if len(lvl.Apps) > 0 {
|
||||
fmt.Fprintf(&b, "# apps:\n")
|
||||
for k, v := range lvl.Apps {
|
||||
fmt.Fprintf(&b, "# %s: %s\n", k, v)
|
||||
}
|
||||
}
|
||||
if len(lvl.Tables) > 0 {
|
||||
fmt.Fprintf(&b, "# tables:\n")
|
||||
for k, v := range lvl.Tables {
|
||||
fmt.Fprintf(&b, "# %s: %s\n", k, v)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
// effectiveSourceView is the wire shape for one entry in the
|
||||
// `sources` array of the ?effective=1 response. Level matches
|
||||
// zddc.SourceEntry.Level (-1 = embedded baseline, 0+ = chain index);
|
||||
// URL is the directory URL of that level (or "<embedded>" for the
|
||||
// baseline); Contributed lists the top-level fields the level
|
||||
// declared.
|
||||
type effectiveSourceView struct {
|
||||
Level int `json:"level"`
|
||||
URL string `json:"url"`
|
||||
Contributed []string `json:"contributed,omitempty"`
|
||||
}
|
||||
|
||||
// effectiveZddcView is the wire shape for the ?effective=1 response.
|
||||
// Merged is the composed cascade as a ZddcFile (same struct shape the
|
||||
// editor consumes for an on-disk .zddc; client-side renderers can
|
||||
// reuse the same parser). Sources lists per-level contributions so
|
||||
// the user can trace any value back to its origin without re-walking
|
||||
// the cascade by hand.
|
||||
type effectiveZddcView struct {
|
||||
URLPath string `json:"url_path"`
|
||||
Merged zddc.ZddcFile `json:"merged"`
|
||||
Sources []effectiveSourceView `json:"sources"`
|
||||
}
|
||||
|
||||
// serveEffectiveZddc writes the JSON composed-cascade view for the
|
||||
// .zddc URL. Same ACL as the YAML view (already enforced by the
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package handler
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"title: bootstrap\nacl:\n permissions:\n \"*\": rwcda\n")
|
||||
// Directory exists but has no .zddc.
|
||||
subDir := filepath.Join(root, "Project")
|
||||
if err := os.Mkdir(subDir, 0o755); err != nil {
|
||||
|
|
@ -93,15 +104,273 @@ func TestServeZddcFile_VirtualDefault(t *testing.T) {
|
|||
}
|
||||
body := rec.Body.String()
|
||||
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, "bootstrap") {
|
||||
t.Errorf("body missing root cascade summary: %q", body)
|
||||
if !strings.Contains(body, "?effective=1") {
|
||||
t.Errorf("body missing pointer to the composed-view query: %q", body)
|
||||
}
|
||||
// Should parse as valid YAML (empty document or {} at the end).
|
||||
if !strings.Contains(body, "{}") {
|
||||
t.Errorf("body missing placeholder body: %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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -272,6 +272,141 @@ func (chain PolicyChain) EffectiveRecordRule(basename string) (string, RecordRul
|
|||
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.
|
||||
func InvalidateCache(dirPath string) {
|
||||
dirPath = filepath.Clean(dirPath)
|
||||
|
|
|
|||
|
|
@ -34,22 +34,53 @@ acl:
|
|||
# the reset are then excluded.
|
||||
#
|
||||
# document_controller — the people who file into
|
||||
# archive/<party>/received/ and issued/ (WORM zones). They get
|
||||
# read+write-once-create there (via the worm: lists below) and
|
||||
# read/write elsewhere in a project, plus subtree-admin of the
|
||||
# per-party working/ + staging/ + reviewing/ so they can stand up
|
||||
# and manage drafting/transmittal/review folders. They are NOT
|
||||
# subtree-admin of archive/<party>/, so the WORM constraint still
|
||||
# binds them in received/issued. 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.
|
||||
# archive/<party>/received/ and issued/ (WORM zones). They get:
|
||||
# - rwcda at every archive/<party>/ via the role grant written
|
||||
# into each party's auto-own .zddc (auto_own_roles below).
|
||||
# Cascade carries rwcda down to descendants by default.
|
||||
# - read+write-once-create at received/issued via the worm:
|
||||
# lists (the WORM mask strips w/d/a even though the role
|
||||
# grant supplies rwcda at the party level above).
|
||||
# - rwcd explicit at incoming/ and staging/ (the QC and
|
||||
# transmittal-out workflows need `d` to move files between
|
||||
# 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
|
||||
# the project. Their own archive/<party>/working/<email>/ home and
|
||||
# anything they create under incoming/ get a creator-owned auto-
|
||||
# own .zddc (rwcda) which wins via deepest-match, so "read-only
|
||||
# except what I own" falls out of the cascade with no special rule.
|
||||
# the project by default, with a one-way ratchet through the
|
||||
# in-flight slots:
|
||||
#
|
||||
# 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
|
||||
# but with no auto-own home: an observer who somehow created a
|
||||
|
|
@ -197,19 +228,31 @@ paths:
|
|||
paths:
|
||||
# Second segment under archive/ is the party name.
|
||||
"*":
|
||||
# When the doc controller creates a party folder, an
|
||||
# auto-own .zddc grants them rwcda there (UNFENCED — so
|
||||
# the project-level project_team:r still cascades through
|
||||
# to received/issued). That lets them set up the
|
||||
# counterparty's own .zddc afterward.
|
||||
# When the doc controller creates a party folder, the
|
||||
# auto-own .zddc grants:
|
||||
# - the creator's email rwcda (the standard auto_own
|
||||
# mechanism)
|
||||
# - 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
|
||||
# Doc controller is subtree-admin of this party folder —
|
||||
# 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]
|
||||
auto_own_roles: [document_controller]
|
||||
# SSR record: the party folder's ssr.yaml carries this
|
||||
# party's vendor / contract / status data. Scoped by
|
||||
# filename pattern so the lock on `kind` only applies to
|
||||
|
|
@ -324,9 +367,49 @@ paths:
|
|||
# level <project>/{working,staging,reviewing} virtuals
|
||||
# (declared above) are folder-nav views over these
|
||||
# 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:
|
||||
default_tool: browse
|
||||
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
|
||||
# homes below.
|
||||
auto_own: true
|
||||
|
|
@ -345,6 +428,32 @@ paths:
|
|||
staging:
|
||||
default_tool: transmittal
|
||||
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
|
||||
drop_target: true
|
||||
reviewing:
|
||||
|
|
@ -359,5 +468,21 @@ paths:
|
|||
# from the party-level admins:) so the doc
|
||||
# controller can author per-folder .zddc files
|
||||
# (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
|
||||
drop_target: true
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
|||
absPath string
|
||||
autoOwn bool
|
||||
fenced bool
|
||||
roles []string
|
||||
}
|
||||
var freshlyCreated []created
|
||||
|
||||
|
|
@ -224,10 +225,15 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
|||
_ = i
|
||||
autoOwn := AutoOwnAt(fsRoot, pathSoFar)
|
||||
fenced := autoOwn && AutoOwnFencedAt(fsRoot, pathSoFar)
|
||||
var roles []string
|
||||
if autoOwn {
|
||||
roles = AutoOwnRolesAt(fsRoot, pathSoFar)
|
||||
}
|
||||
freshlyCreated = append(freshlyCreated, created{
|
||||
absPath: pathSoFar,
|
||||
autoOwn: autoOwn,
|
||||
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
|
||||
// system writes). The fenced variant is used at per-user home
|
||||
// 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 != "" {
|
||||
for _, c := range freshlyCreated {
|
||||
if !c.autoOwn {
|
||||
|
|
@ -243,9 +252,9 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
|||
}
|
||||
var werr error
|
||||
if c.fenced {
|
||||
werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail)
|
||||
werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail, c.roles)
|
||||
} else {
|
||||
werr = WriteAutoOwnZddc(c.absPath, principalEmail)
|
||||
werr = WriteAutoOwnZddc(c.absPath, principalEmail, c.roles)
|
||||
}
|
||||
if werr != nil {
|
||||
return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, werr)
|
||||
|
|
|
|||
|
|
@ -125,9 +125,9 @@ type ConvertMetadata struct {
|
|||
// place = URL-fetched apps refused (only embedded + local-path apps
|
||||
// work). See zddc-server's setupApps.
|
||||
type ZddcFile struct {
|
||||
ACL ACLRules `yaml:"acl" json:"acl"`
|
||||
Admins []string `yaml:"admins" json:"admins,omitempty"`
|
||||
Title string `yaml:"title" json:"title,omitempty"`
|
||||
ACL ACLRules `yaml:"acl,omitempty" json:"acl,omitempty"`
|
||||
Admins []string `yaml:"admins,omitempty" json:"admins,omitempty"`
|
||||
Title string `yaml:"title,omitempty" json:"title,omitempty"`
|
||||
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
|
||||
AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`
|
||||
|
||||
|
|
@ -231,6 +231,23 @@ type ZddcFile struct {
|
|||
// admin grants still apply.
|
||||
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
|
||||
// server treats requests under such a path as virtual routes
|
||||
// rather than triggering EnsureCanonicalAncestors. The reviewing
|
||||
|
|
|
|||
|
|
@ -75,6 +75,23 @@ func AutoOwnAt(fsRoot, dirPath string) bool {
|
|||
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
|
||||
// should be written with `inherit: false` (private to creator).
|
||||
// Leaf-only, same semantic as AutoOwnAt.
|
||||
|
|
|
|||
|
|
@ -11,19 +11,24 @@ import (
|
|||
// IsDeclaredPath, ChildrenDeclaredAt, AvailableToolsAt).
|
||||
|
||||
// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
|
||||
// principalEmail rwcda and recording it in CreatedBy. Used by the file
|
||||
// API's mkdir post-hook (and by EnsureCanonicalAncestors) to seed
|
||||
// ownership when a new auto-own folder is materialised.
|
||||
// principalEmail rwcda and recording it in CreatedBy. Each role name
|
||||
// in roles also receives rwcda — gives the schema a way to declare
|
||||
// "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 —
|
||||
// direct email pattern, "rwcda" verb set — so the creator can later
|
||||
// edit the file normally to add collaborators.
|
||||
// The grants are identical to what an operator would write by hand —
|
||||
// direct email pattern + bare role names, "rwcda" verb set — so the
|
||||
// creator can later edit the file normally to narrow or extend them.
|
||||
//
|
||||
// Atomic: marshals via the same yaml encoder ParseFile reads
|
||||
// (round-trip guaranteed) and writes via zddc.WriteFile (which
|
||||
// performs an atomic temp-write + rename via zddc.WriteAtomic).
|
||||
func WriteAutoOwnZddc(dir, principalEmail string) error {
|
||||
return writeAutoOwn(dir, principalEmail, false)
|
||||
func WriteAutoOwnZddc(dir, principalEmail string, roles []string) error {
|
||||
return writeAutoOwn(dir, principalEmail, false, roles)
|
||||
}
|
||||
|
||||
// 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
|
||||
// authenticated users) would let any user read every other user's
|
||||
// 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 {
|
||||
rules := ACLRules{
|
||||
Permissions: map[string]string{principalEmail: "rwcda"},
|
||||
func writeAutoOwn(dir, principalEmail string, fenced bool, roles []string) error {
|
||||
perms := 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 {
|
||||
f := false
|
||||
rules.Inherit = &f
|
||||
|
|
|
|||
|
|
@ -6,29 +6,53 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
// TestStandardRoles_DocControllerScopedCreate — with document_controller
|
||||
// populated at the on-disk root, the role gets:
|
||||
// - rw at the project level (read + overwrite-existing), but NOT c
|
||||
// (so it can't make arbitrary folders)
|
||||
// TestStandardRoles_DocControllerScopedCreate — DC authority comes
|
||||
// PURELY from the cascade now (no subtree-admin / admins: list). The
|
||||
// model:
|
||||
// - rw at the project level (read + overwrite-existing, no `c`)
|
||||
// - rwc at archive/ (can create party subfolders)
|
||||
// - subtree-admin at archive/<party>/ (full create + manage; lifecycle
|
||||
// slots under the party inherit the admin grant)
|
||||
// - inside received/issued (WORM): masked to r + worm-restored c
|
||||
//
|
||||
// Layout reshape: working/staging/reviewing moved from project root
|
||||
// into archive/<party>/, so the subtree-admin scope likewise moved
|
||||
// from project-level "working/staging/" to the per-party folder.
|
||||
// - When DC mkdirs archive/<party>/, ensure.go writes an auto-own
|
||||
// .zddc granting both the creator email AND the document_controller
|
||||
// role rwcda there (via auto_own_roles in defaults). This test
|
||||
// simulates that .zddc directly so the cascade behaviour can be
|
||||
// asserted in isolation.
|
||||
// - From the party's auto-own .zddc, the role rwcda cascades down to
|
||||
// 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) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
// Deployment populates the standard roles. Roles UNION with the
|
||||
// embedded (empty) definitions, so this is the effective member set.
|
||||
// DCs are typically internal employees and ARE in project_team
|
||||
// (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:
|
||||
document_controller:
|
||||
members: ["dc@example.com"]
|
||||
project_team:
|
||||
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"
|
||||
|
||||
mustVerbs := func(dir string, want string) {
|
||||
|
|
@ -37,7 +61,6 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("EffectivePolicy(%q): %v", dir, err)
|
||||
}
|
||||
// Mirror InternalDecider.Allow's WORM-aware composition.
|
||||
var got VerbSet
|
||||
if g, inWorm := WormZoneGrant(chain, dc); inWorm {
|
||||
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")
|
||||
// archive/: rwc (can create party folders).
|
||||
mustVerbs(filepath.Join(root, "Proj", "archive"), "rwc")
|
||||
// incoming/: rwcd — the QC + transfer-out workflow needs delete.
|
||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "incoming"), "rwcd")
|
||||
// received/ (WORM): rw masked to r, plus worm-restored c → "rc".
|
||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "rc")
|
||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "rc")
|
||||
// At the party folder itself: rwcda via the auto-own role grant.
|
||||
mustVerbs(partyDir, "rwcda")
|
||||
// Lifecycle slots inside the party folder inherit rwcda from the
|
||||
// party-level role grant where no slot-local grant overrides.
|
||||
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
|
||||
// declares admins: [document_controller] on the party "*" entry,
|
||||
// so working/staging/reviewing inside the party inherit it).
|
||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme"), Principal{Email: dc, Elevated: true}) {
|
||||
t.Errorf("doc controller should be subtree-admin of archive/<party>/")
|
||||
// NOT subtree-admin anywhere — even when notionally elevated,
|
||||
// the role carries no admin: grant.
|
||||
for _, p := range []string{
|
||||
filepath.Join(root, "Proj"),
|
||||
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/")
|
||||
// And specifically — they CAN'T reach inside a fenced per-user
|
||||
// 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", "staging"), Principal{Email: dc, Elevated: true}) {
|
||||
t.Errorf("doc controller should be subtree-admin of archive/<party>/staging/")
|
||||
writeZddc(t, homeDir, `acl:
|
||||
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())
|
||||
}
|
||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "reviewing"), Principal{Email: dc, Elevated: true}) {
|
||||
t.Errorf("doc controller should be subtree-admin of archive/<party>/reviewing/")
|
||||
}
|
||||
|
||||
// TestStandardRoles_DocControllerMultiDC — a second DC added to the
|
||||
// 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)
|
||||
}
|
||||
// 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}) {
|
||||
t.Errorf("doc controller should NOT be subtree-admin of archive/ (only of each party folder)")
|
||||
writeZddc(t, partyDir, `acl:
|
||||
permissions:
|
||||
"dc1@example.com": rwcda
|
||||
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())
|
||||
}
|
||||
// Subtree-admin reaches inside a fenced per-user working home
|
||||
// under the party's working slot.
|
||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), Principal{Email: dc, Elevated: true}) {
|
||||
t.Errorf("doc controller (subtree-admin of party) should reach inside a fenced user home")
|
||||
// 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
|
||||
// project-wide read-only role for auditors / regulators / external
|
||||
// viewers. Unlike project_team, an observer must not contribute
|
||||
|
|
|
|||
|
|
@ -78,6 +78,13 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
|||
if top.AutoOwnFenced != nil {
|
||||
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 {
|
||||
out.DropTarget = top.DropTarget
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue