Compare commits
No commits in common. "b4d59b11ee18a962fc145206a5517cff81601284" and "360049f48248ff964fc3cf780cd004b2f8f0d4cf" have entirely different histories.
b4d59b11ee
...
360049f482
26 changed files with 277 additions and 2480 deletions
24
AGENTS.md
24
AGENTS.md
|
|
@ -494,22 +494,14 @@ roles:
|
||||||
- auditor@regulator.gov
|
- auditor@regulator.gov
|
||||||
```
|
```
|
||||||
|
|
||||||
The embedded cascade already grants `project_team: r` and `observer: r` project-wide, and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, `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 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 in-flight lifecycle slots form a one-way ratchet:
|
|
||||||
|
|
||||||
`working/` → `staging/` → `issued/` (WORM)
|
|
||||||
|
|
||||||
Each handoff drops `project_team`'s modify rights for the slot they pushed from. At `working/` they have `cr` plus `rwcda` inside their auto-own-fenced `<email>/` home. At `staging/` they have `cr` only (drop files, no modify after). DC takes over with `rwcd` at staging and files to `issued/` via the WORM `cr` grant. Same shape on the inbound side via `incoming/` → `received/` (WORM).
|
|
||||||
|
|
||||||
Pick a role per persona:
|
Pick a role per persona:
|
||||||
|
|
||||||
- `document_controller` — per-party records custodian; files into WORM `received/issued`, manages the `working/staging/reviewing` lifecycle, QCs the counterparty's drops in `incoming/`. **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.
|
- `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. 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`).
|
- `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`).
|
||||||
- `observer` — pure read-only across the project. No auto-own home (the role itself has no `c` anywhere). Intended for auditors, regulators, and external read-only viewers who must not contribute content.
|
- `observer` — pure read-only across the project. No auto-own home (the role itself has no `c` anywhere). Intended for auditors, regulators, and external read-only viewers who must not contribute content.
|
||||||
|
|
||||||
**Roles overlap on purpose.** DCs are typically internal employees and ARE in `project_team` (often defined as `*@example.com`). The cascade is "deepest level that has any matching principal wins for that level, with within-level UNION of all matched principals". To prevent a `project_team: cr` grant at the slot from shadowing a DC's role-level `rwcda` inherited from the party folder, the embedded defaults RESTATE `document_controller: rwcda` at every slot that has a project_team-specific grant (`working/`, `staging/`, `reviewing/`). Within-level union → DC gets `rwcda` ∪ `cr` = `rwcda`. Operators adding new slot-level project_team grants in their own `.zddc` files should follow the same pattern. (Internal `observer` users matched by the project_team wildcard would still be lifted to `cr` by the union — observer is intended for EXTERNAL auditors whose emails don't match the wildcard. Deployments with internal observers should use explicit project_team membership instead of a wildcard.)
|
|
||||||
|
|
||||||
**Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`):
|
**Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`):
|
||||||
|
|
||||||
- `acl: { permissions: { <principal>: <bits> }, inherit: <bool>? }` — there is no `allow:` key; an `allow:` block parses cleanly but is silently dropped during unmarshal. Real footgun — easy to write `acl: { allow: [...] }` and assume it works.
|
- `acl: { permissions: { <principal>: <bits> }, inherit: <bool>? }` — there is no `allow:` key; an `allow:` block parses cleanly but is silently dropped during unmarshal. Real footgun — easy to write `acl: { allow: [...] }` and assume it works.
|
||||||
|
|
@ -517,18 +509,8 @@ Pick a role per persona:
|
||||||
- Principals: email (must contain `@`), glob (`*@domain.com`), or role name (no `@`).
|
- Principals: email (must contain `@`), glob (`*@domain.com`), or role name (no `@`).
|
||||||
- `roles: { <name>: { members: [...], reset: <bool>? } }` — members union across the cascade unless `reset: true`.
|
- `roles: { <name>: { members: [...], reset: <bool>? } }` — members union across the cascade unless `reset: true`.
|
||||||
- `admins: [<email>, ...]` — root only; sudo-style elevation per request.
|
- `admins: [<email>, ...]` — root only; sudo-style elevation per request.
|
||||||
- `auto_own: <bool>` — when true, ensure.go writes a `.zddc` granting the creator's email `rwcda` on first mkdir.
|
|
||||||
- `auto_own_fenced: <bool>` — adds `inherit: false` to the auto-own `.zddc` (private-by-default home). No effect without `auto_own: true`.
|
|
||||||
- `auto_own_roles: [<role>, ...]` — additional role names that get `rwcda` in the auto-own `.zddc`, alongside the creator's email. Lets the schema express role-level peer authority without `admins:` (which would be subtree-admin and bypass WORM/fences via elevation).
|
|
||||||
- `title:` — read only from the per-project `.zddc`; surfaces on the landing-page picker.
|
- `title:` — read only from the per-project `.zddc`; surfaces on the landing-page picker.
|
||||||
|
|
||||||
**Two `inherit` scopes, one word.** `ZddcFile.Inherit` (top-level) drops the embedded baseline AND fences ancestor on-disk `.zddc` files from this point of the cascade. `ACLRules.Inherit` (nested under `acl:`) is narrower — it only fences ACL evaluation; embedded roles, paths-tree contributions, WORM lists, and other non-ACL keys still cascade through. Concretely:
|
|
||||||
|
|
||||||
- To opt out of embedded defaults at deployment, set `inherit: false` at the root `<ZDDC_ROOT>/.zddc` (top-level).
|
|
||||||
- To make a per-user home private (block ancestor read grants) but keep cascade-derived behaviour like default_tool, set `acl: { inherit: false }`. The auto-own-fenced mechanism uses this form.
|
|
||||||
|
|
||||||
These are NOT interchangeable. A note about which one operators want lives in `cascade.go:13-21` (the `PolicyChain` doc) and the relevant struct fields in `file.go`.
|
|
||||||
|
|
||||||
Run `zddc-server show-defaults` to dump the embedded `defaults.zddc.yaml` with annotated comments — that's the full schema with all the cascade keys (`worm:`, `auto_own:`, `drop_target:`, `apps:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.).
|
Run `zddc-server show-defaults` to dump the embedded `defaults.zddc.yaml` with annotated comments — that's the full schema with all the cascade keys (`worm:`, `auto_own:`, `drop_target:`, `apps:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.).
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
|
||||||
|
|
@ -715,7 +715,7 @@ The schema keys that drive built-in behavior:
|
||||||
|
|
||||||
**Standard roles.** `defaults.zddc.yaml` references three roles (all shipped empty — a fresh deployment grants nothing until an operator populates them):
|
**Standard roles.** `defaults.zddc.yaml` references three roles (all shipped empty — a fresh deployment grants nothing until an operator populates them):
|
||||||
|
|
||||||
- `document_controller` — read/write across a project, `rwc` at `archive/`. 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.
|
- `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.
|
||||||
- `project_team` — read-only across the project; their own `archive/<party>/working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule.
|
- `project_team` — read-only across the project; their own `archive/<party>/working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule.
|
||||||
- `observer` — pure read-only across the project. Distinct from `project_team` in that the role itself carries no `c` anywhere, so an observer can't bring a working home into existence under auto-own. Intended for auditors, regulators, and external read-only viewers who must not contribute content.
|
- `observer` — pure read-only across the project. Distinct from `project_team` in that the role itself carries no `c` anywhere, so an observer can't bring a working home into existence under auto-own. Intended for auditors, regulators, and external read-only viewers who must not contribute content.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,27 +72,15 @@
|
||||||
// clicked a header link, the URL bar, etc. without moving to
|
// clicked a header link, the URL bar, etc. without moving to
|
||||||
// another row first). focusout fires for cell-to-cell moves
|
// another row first). focusout fires for cell-to-cell moves
|
||||||
// too — relatedTarget being outside #table-root distinguishes.
|
// too — relatedTarget being outside #table-root distinguishes.
|
||||||
//
|
|
||||||
// Deferred to next tick (setTimeout 0): the editor's commit
|
|
||||||
// path tears down its input element and then refocuses the
|
|
||||||
// owning cell. The remove fires focusout BEFORE the refocus
|
|
||||||
// runs, with relatedTarget=null (body briefly), so the naive
|
|
||||||
// sync check would mis-detect a "left the grid" event and
|
|
||||||
// fire flushAll redundantly alongside the selection-change
|
|
||||||
// save. Checking document.activeElement on the next tick
|
|
||||||
// gives the refocus time to settle.
|
|
||||||
const tableRoot = document.getElementById('table-root');
|
const tableRoot = document.getElementById('table-root');
|
||||||
if (tableRoot) {
|
if (tableRoot) {
|
||||||
tableRoot.addEventListener('focusout', function (ev) {
|
tableRoot.addEventListener('focusout', function (ev) {
|
||||||
const next = ev.relatedTarget;
|
const next = ev.relatedTarget;
|
||||||
if (next && tableRoot.contains(next)) return;
|
if (next && tableRoot.contains(next)) return;
|
||||||
setTimeout(function () {
|
|
||||||
if (tableRoot.contains(document.activeElement)) return;
|
|
||||||
const save = app.modules.save;
|
const save = app.modules.save;
|
||||||
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
|
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
|
||||||
save.flushAll();
|
save.flushAll();
|
||||||
}
|
}
|
||||||
}, 0);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,42 +149,30 @@ test.describe('Browse', () => {
|
||||||
await expect(page.locator('#previewBody')).toContainText('a note inside the zip');
|
await expect(page.locator('#previewBody')).toContainText('a note inside the zip');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Download (zip) bundles a folder via right-click → Download ZIP', async ({ page }) => {
|
test('Download (zip) bundles the current folder offline', async ({ page }) => {
|
||||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
|
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
|
||||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||||
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.__setMockDirectoryTree('mock-folder', {
|
window.__setMockDirectoryTree('mock-folder', {
|
||||||
'a.txt': 'AAA',
|
'a.txt': 'AAA',
|
||||||
'sub': {
|
'sub': { 'b.txt': 'BBB', 'deep': { 'c.txt': 'CCC' } },
|
||||||
'b.txt': 'BBB',
|
|
||||||
'deep': { 'c.txt': 'CCC' },
|
|
||||||
'.zddc': 'acl: { permissions: { "*": r } }', // hidden — must not be in the zip
|
'.zddc': 'acl: { permissions: { "*": r } }', // hidden — must not be in the zip
|
||||||
'_template': { 'scaffold.txt': 'x' }, // hidden dir — must not be in the zip
|
'_template': { 'scaffold.txt': 'x' }, // hidden dir — must not be in the zip
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await page.locator('#addDirectoryBtn').click();
|
await page.locator('#addDirectoryBtn').click();
|
||||||
await page.waitForSelector('#browseRoot:not(.hidden)', { timeout: 10000 });
|
await page.waitForSelector('#browseRoot:not(.hidden)', { timeout: 10000 });
|
||||||
|
|
||||||
// Download ZIP lives in the row's right-click context menu —
|
// The Download (zip) button appears once a directory is loaded.
|
||||||
// the standalone toolbar button was retired when the context
|
const dlBtn = page.locator('#downloadZipBtn');
|
||||||
// menu became the canonical action surface (SPA overhaul,
|
await expect(dlBtn).toBeVisible();
|
||||||
// commit 94b2e29). The picked-root folder doesn't render as
|
|
||||||
// a row (only its CONTENTS do), so we test the next-level
|
|
||||||
// folder: right-click sub/, download sub.zip.
|
|
||||||
const subRow = page.locator('.tree-row', { has: page.locator('.tree-name__label', { hasText: /^sub$/ }) });
|
|
||||||
await subRow.waitFor({ state: 'visible', timeout: 5000 });
|
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent('download'),
|
page.waitForEvent('download'),
|
||||||
(async () => {
|
dlBtn.click(),
|
||||||
await subRow.click({ button: 'right' });
|
|
||||||
await page.locator('.zddc-menu__item', { hasText: 'Download ZIP' })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
})(),
|
|
||||||
]);
|
]);
|
||||||
expect(download.suggestedFilename()).toBe('sub.zip');
|
expect(download.suggestedFilename()).toBe('mock-folder.zip');
|
||||||
|
|
||||||
const file = await download.path();
|
const file = await download.path();
|
||||||
const buf = await fs.readFile(file);
|
const buf = await fs.readFile(file);
|
||||||
|
|
@ -202,8 +190,9 @@ test.describe('Browse', () => {
|
||||||
return Object.keys(z.files).filter((n) => !z.files[n].dir).sort();
|
return Object.keys(z.files).filter((n) => !z.files[n].dir).sort();
|
||||||
}, b64);
|
}, b64);
|
||||||
expect(entries).toEqual([
|
expect(entries).toEqual([
|
||||||
'sub/b.txt',
|
'mock-folder/a.txt',
|
||||||
'sub/deep/c.txt',
|
'mock-folder/sub/b.txt',
|
||||||
|
'mock-folder/sub/deep/c.txt',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -45,16 +45,15 @@ for (const tool of tools) {
|
||||||
const match = html.match(/class="build-timestamp">(?:<span[^>]*>)?([^<]+?)(?:<\/span>)?</);
|
const match = html.match(/class="build-timestamp">(?:<span[^>]*>)?([^<]+?)(?:<\/span>)?</);
|
||||||
expect(match, 'build-timestamp element must have text content').toBeTruthy();
|
expect(match, 'build-timestamp element must have text content').toBeTruthy();
|
||||||
const label = match[1];
|
const label = match[1];
|
||||||
// Plain dev builds, ./build beta, and the retired
|
// Plain dev builds and --release alpha|beta share one label
|
||||||
// ./build alpha all share one label shape — full UTC
|
// shape — full UTC timestamp + short source SHA (with
|
||||||
// timestamp + short source SHA (with optional -dirty
|
// optional -dirty marker on plain dev when the tree is
|
||||||
// marker when the tree is uncommitted):
|
// 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"
|
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6"
|
||||||
|
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6-dirty"
|
||||||
|
// "v0.0.17-beta · 2026-05-13 15:29:05 · e7f6334"
|
||||||
// Stable cuts emit a bare version: "v0.0.17"
|
// Stable cuts emit a bare version: "v0.0.17"
|
||||||
const isChannel = /^v\d+\.\d+\.\d+-(?:alpha|beta|dev) · 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) · 20\d\d-\d\d-\d\d \d\d:\d\d:\d\d · [0-9a-f]+(?:-dirty)?$/.test(label);
|
||||||
const isVersion = /^v\d+\.\d+\.\d+$/.test(label);
|
const isVersion = /^v\d+\.\d+\.\d+$/.test(label);
|
||||||
expect(isChannel || isVersion,
|
expect(isChannel || isVersion,
|
||||||
`Expected channel or version label, got: "${label}"`
|
`Expected channel or version label, got: "${label}"`
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,10 @@ const FETCH_MOCK = `
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Listing entries match the post-reshape FileInfo shape — the
|
|
||||||
// landing's loader filters on `is_dir: true` (projects are folders).
|
|
||||||
// Older fixtures omitted this and silently filtered to empty.
|
|
||||||
const SAMPLE_PROJECTS = [
|
const SAMPLE_PROJECTS = [
|
||||||
{ name: '176109/', is_dir: true, url: '/176109/', title: 'Greenfield Substation' },
|
{ name: '176109', url: '/176109/', title: 'Greenfield Substation' },
|
||||||
{ name: '197072/', is_dir: true, url: '/197072/', title: 'Brownfield Tap' },
|
{ name: '197072', url: '/197072/', title: 'Brownfield Tap' },
|
||||||
{ name: '210045/', is_dir: true, url: '/210045/', title: '' },
|
{ name: '210045', url: '/210045/', title: '' },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function loadLandingWithProjects(page, projects) {
|
async function loadLandingWithProjects(page, projects) {
|
||||||
|
|
|
||||||
|
|
@ -836,25 +836,6 @@ body.help-open .app-header {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline action button appended to a toast by zddc.cap.handleForbidden
|
|
||||||
when an Elevate path is offered. Stops click propagation on its own
|
|
||||||
so clicking the button doesn't also dismiss the toast. */
|
|
||||||
.zddc-toast__action {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 0.75rem;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
background: var(--accent, var(--text));
|
|
||||||
color: var(--bg);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.zddc-toast__action:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||||
Renders only for users with admin scope (handled by elevation.js;
|
Renders only for users with admin scope (handled by elevation.js;
|
||||||
the placeholder is `.hidden` by default). When visible, sits left
|
the placeholder is `.hidden` by default). When visible, sits left
|
||||||
|
|
@ -2582,7 +2563,7 @@ td[data-field="trackingNumber"] {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp">v0.0.21</span>
|
<span class="build-timestamp">v0.0.19</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||||
|
|
@ -10878,170 +10859,6 @@ window.app.modules.filtering = {
|
||||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/cap.js — client-side capability helpers for permission gating.
|
|
||||||
//
|
|
||||||
// Three small helpers, exposed under window.zddc.cap, that wrap the
|
|
||||||
// server's verbs / /.profile/access?path / 403 missing_verb surface:
|
|
||||||
//
|
|
||||||
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
|
|
||||||
// /.profile/access?path=<urlpath> and
|
|
||||||
// memoises per-path for the session.
|
|
||||||
// Used by tools to gate top-of-page
|
|
||||||
// affordances (Publish, +Add row,
|
|
||||||
// +New folder) on PathVerbs.
|
|
||||||
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
|
|
||||||
// "rwcda"-subset) for the listed verb.
|
|
||||||
// Transition: falls back to
|
|
||||||
// node.writable for 'w' when verbs
|
|
||||||
// is absent, so the legacy field still
|
|
||||||
// drives gating on old listings.
|
|
||||||
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
|
|
||||||
// parses the JSON body for
|
|
||||||
// missing_verb and renders a toast.
|
|
||||||
// Offers "Elevate" when the path's
|
|
||||||
// /.profile/access?path= reports a
|
|
||||||
// path_can_elevate_grant covering the
|
|
||||||
// missing verb.
|
|
||||||
//
|
|
||||||
// Tools using this module must concat shared/cap.js AFTER shared/
|
|
||||||
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
|
||||||
if (window.zddc.cap) return;
|
|
||||||
|
|
||||||
var pathCache = new Map(); // path → AccessView (or null sentinel)
|
|
||||||
|
|
||||||
async function fetchAccess(path) {
|
|
||||||
// file:// pages have no server to fetch /.profile/access from;
|
|
||||||
// calling fetch() there logs a browser-level error before our
|
|
||||||
// catch even runs. Short-circuit so offline tools (browse on
|
|
||||||
// a picked folder, form opened from a file URL) silently
|
|
||||||
// degrade to "no path-scoped info, fall back to existing
|
|
||||||
// gating signals".
|
|
||||||
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var url = '/.profile/access';
|
|
||||||
if (path) url += '?path=' + encodeURIComponent(path);
|
|
||||||
var resp = await fetch(url, {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
cache: 'no-cache'
|
|
||||||
});
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
return await resp.json();
|
|
||||||
} catch (_e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// at(path) — fetch path-scoped access view, memoised per path
|
|
||||||
// within the page session. Cache is page-scoped: any elevation
|
|
||||||
// toggle forces a hard reload (see shared/elevation.js), which
|
|
||||||
// resets the cache so stale-after-elevation isn't a concern. Pass
|
|
||||||
// null/undefined for the global view (no ?path=).
|
|
||||||
async function at(path) {
|
|
||||||
var key = path || '';
|
|
||||||
if (pathCache.has(key)) return pathCache.get(key);
|
|
||||||
var view = await fetchAccess(path);
|
|
||||||
pathCache.set(key, view);
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
// has(node, verb) — check a per-entry verbs string for a single
|
|
||||||
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
|
|
||||||
// Transition shim: when node.verbs is absent, fall back to
|
|
||||||
// node.writable for 'w' so the legacy field keeps editor save
|
|
||||||
// buttons working on old listings — drop this fallback once every
|
|
||||||
// tool's loader sets node.verbs unconditionally.
|
|
||||||
function has(node, verb) {
|
|
||||||
if (!node) return false;
|
|
||||||
if (typeof node.verbs === 'string') {
|
|
||||||
return node.verbs.indexOf(verb) !== -1;
|
|
||||||
}
|
|
||||||
if (verb === 'w' && typeof node.writable === 'boolean') {
|
|
||||||
return node.writable;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
|
|
||||||
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
|
|
||||||
var VERB_LABELS = {
|
|
||||||
r: 'read',
|
|
||||||
w: 'write',
|
|
||||||
c: 'create',
|
|
||||||
d: 'delete',
|
|
||||||
a: 'edit access rules'
|
|
||||||
};
|
|
||||||
|
|
||||||
// handleForbidden(resp, opts) — render a 403 toast naming the
|
|
||||||
// missing verb. opts.path (optional) is the URL the failed request
|
|
||||||
// hit; when provided, the helper consults /.profile/access?path= to
|
|
||||||
// decide whether to offer an Elevate action. opts.context is an
|
|
||||||
// optional string prefix shown before the verb message ("Save",
|
|
||||||
// "Delete", etc.) — purely cosmetic.
|
|
||||||
//
|
|
||||||
// Best-effort: when the body isn't JSON or missing_verb is
|
|
||||||
// absent, falls back to a plain "Forbidden" toast. Returns the
|
|
||||||
// Promise so callers can await before chaining.
|
|
||||||
async function handleForbidden(resp, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
var missing = '';
|
|
||||||
try {
|
|
||||||
var body = await resp.clone().json();
|
|
||||||
if (body && typeof body.missing_verb === 'string') {
|
|
||||||
missing = body.missing_verb;
|
|
||||||
}
|
|
||||||
} catch (_e) { /* non-JSON body */ }
|
|
||||||
|
|
||||||
var prefix = opts.context ? (opts.context + ': ') : '';
|
|
||||||
var verbLabel = VERB_LABELS[missing] || missing || '';
|
|
||||||
var msg;
|
|
||||||
if (verbLabel) {
|
|
||||||
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
|
|
||||||
} else {
|
|
||||||
msg = prefix + 'Forbidden.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional elevate offer: only when the caller supplied a
|
|
||||||
// path AND the path-scoped access view reports an elevation
|
|
||||||
// grant covering the missing verb. Render as a clickable
|
|
||||||
// action appended to the toast message; clicking sets the
|
|
||||||
// elevation cookie and reloads, matching the header toggle.
|
|
||||||
var canOffer = false;
|
|
||||||
if (opts.path && missing) {
|
|
||||||
var view = await at(opts.path);
|
|
||||||
if (view && typeof view.path_can_elevate_grant === 'string'
|
|
||||||
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
|
|
||||||
canOffer = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var toastFn = (window.zddc && window.zddc.toast) || function () {};
|
|
||||||
var el = toastFn(msg, 'error', { durationMs: 8000 });
|
|
||||||
if (canOffer && el && el.appendChild) {
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'zddc-toast__action';
|
|
||||||
btn.textContent = 'Elevate';
|
|
||||||
btn.addEventListener('click', function (ev) {
|
|
||||||
ev.stopPropagation(); // don't dismiss the toast
|
|
||||||
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
|
|
||||||
window.zddc.elevation.setElevated(true);
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
el.appendChild(btn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
|
||||||
})();
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -836,25 +836,6 @@ body.help-open .app-header {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline action button appended to a toast by zddc.cap.handleForbidden
|
|
||||||
when an Elevate path is offered. Stops click propagation on its own
|
|
||||||
so clicking the button doesn't also dismiss the toast. */
|
|
||||||
.zddc-toast__action {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 0.75rem;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
background: var(--accent, var(--text));
|
|
||||||
color: var(--bg);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.zddc-toast__action:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||||
inherits the logo's box and adds a subtle hover/focus affordance
|
inherits the logo's box and adds a subtle hover/focus affordance
|
||||||
so it reads as clickable without altering the logo's visual weight. */
|
so it reads as clickable without altering the logo's visual weight. */
|
||||||
|
|
@ -2369,7 +2350,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Browse</span>
|
<span class="app-header__title">ZDDC Browse</span>
|
||||||
<span class="build-timestamp">v0.0.21</span>
|
<span class="build-timestamp">v0.0.19</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||||
|
|
@ -5375,11 +5356,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
//
|
//
|
||||||
// `items` is an array (or a function returning an array, evaluated
|
// `items` is an array (or a function returning an array, evaluated
|
||||||
// against `context` at open-time). Each entry is one of:
|
// against `context` at open-time). Each entry is one of:
|
||||||
// { label, action, icon?, accel?, disabled?, visible?, danger?, tooltip? }
|
// { label, action, icon?, accel?, disabled?, visible?, danger? }
|
||||||
// — a normal menu item; `action(ctx)` fires on click/Enter.
|
// — a normal menu item; `action(ctx)` fires on click/Enter.
|
||||||
// `tooltip` (string or fn(ctx)) sets the row's title attribute —
|
|
||||||
// useful for explaining WHY a disabled item is unavailable
|
|
||||||
// ("You don't have write access here", etc.).
|
|
||||||
// { label, checked, action, ... }
|
// { label, checked, action, ... }
|
||||||
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
|
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
|
||||||
// a ✓ in the gutter when truthy.
|
// a ✓ in the gutter when truthy.
|
||||||
|
|
@ -5390,10 +5368,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
// are collapsed automatically so callers can build items
|
// are collapsed automatically so callers can build items
|
||||||
// conditionally without managing dividers.
|
// conditionally without managing dividers.
|
||||||
//
|
//
|
||||||
// Any of `label`, `checked`, `visible`, `disabled`, `tooltip`, and
|
// Any of `label`, `checked`, `visible`, `disabled`, and `items` may
|
||||||
// `items` may be a function — each is invoked with the context object
|
// be a function — each is invoked with the context object so callers
|
||||||
// so callers can render fully context-aware menus from a single
|
// can render fully context-aware menus from a single declarative
|
||||||
// declarative config.
|
// config.
|
||||||
//
|
//
|
||||||
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
|
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
|
||||||
// submenu, ArrowLeft / Escape backs up one level (or closes if
|
// submenu, ArrowLeft / Escape backs up one level (or closes if
|
||||||
|
|
@ -5515,10 +5493,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
row.classList.add('is-disabled');
|
row.classList.add('is-disabled');
|
||||||
row.setAttribute('aria-disabled', 'true');
|
row.setAttribute('aria-disabled', 'true');
|
||||||
}
|
}
|
||||||
if ('tooltip' in item) {
|
|
||||||
var tip = resolve(item.tooltip, ctx);
|
|
||||||
if (tip) row.title = String(tip);
|
|
||||||
}
|
|
||||||
row.setAttribute('role',
|
row.setAttribute('role',
|
||||||
hasSub ? 'menuitem'
|
hasSub ? 'menuitem'
|
||||||
: (isToggle ? 'menuitemcheckbox' : 'menuitem'));
|
: (isToggle ? 'menuitemcheckbox' : 'menuitem'));
|
||||||
|
|
@ -5902,170 +5876,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/cap.js — client-side capability helpers for permission gating.
|
|
||||||
//
|
|
||||||
// Three small helpers, exposed under window.zddc.cap, that wrap the
|
|
||||||
// server's verbs / /.profile/access?path / 403 missing_verb surface:
|
|
||||||
//
|
|
||||||
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
|
|
||||||
// /.profile/access?path=<urlpath> and
|
|
||||||
// memoises per-path for the session.
|
|
||||||
// Used by tools to gate top-of-page
|
|
||||||
// affordances (Publish, +Add row,
|
|
||||||
// +New folder) on PathVerbs.
|
|
||||||
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
|
|
||||||
// "rwcda"-subset) for the listed verb.
|
|
||||||
// Transition: falls back to
|
|
||||||
// node.writable for 'w' when verbs
|
|
||||||
// is absent, so the legacy field still
|
|
||||||
// drives gating on old listings.
|
|
||||||
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
|
|
||||||
// parses the JSON body for
|
|
||||||
// missing_verb and renders a toast.
|
|
||||||
// Offers "Elevate" when the path's
|
|
||||||
// /.profile/access?path= reports a
|
|
||||||
// path_can_elevate_grant covering the
|
|
||||||
// missing verb.
|
|
||||||
//
|
|
||||||
// Tools using this module must concat shared/cap.js AFTER shared/
|
|
||||||
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
|
||||||
if (window.zddc.cap) return;
|
|
||||||
|
|
||||||
var pathCache = new Map(); // path → AccessView (or null sentinel)
|
|
||||||
|
|
||||||
async function fetchAccess(path) {
|
|
||||||
// file:// pages have no server to fetch /.profile/access from;
|
|
||||||
// calling fetch() there logs a browser-level error before our
|
|
||||||
// catch even runs. Short-circuit so offline tools (browse on
|
|
||||||
// a picked folder, form opened from a file URL) silently
|
|
||||||
// degrade to "no path-scoped info, fall back to existing
|
|
||||||
// gating signals".
|
|
||||||
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var url = '/.profile/access';
|
|
||||||
if (path) url += '?path=' + encodeURIComponent(path);
|
|
||||||
var resp = await fetch(url, {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
cache: 'no-cache'
|
|
||||||
});
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
return await resp.json();
|
|
||||||
} catch (_e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// at(path) — fetch path-scoped access view, memoised per path
|
|
||||||
// within the page session. Cache is page-scoped: any elevation
|
|
||||||
// toggle forces a hard reload (see shared/elevation.js), which
|
|
||||||
// resets the cache so stale-after-elevation isn't a concern. Pass
|
|
||||||
// null/undefined for the global view (no ?path=).
|
|
||||||
async function at(path) {
|
|
||||||
var key = path || '';
|
|
||||||
if (pathCache.has(key)) return pathCache.get(key);
|
|
||||||
var view = await fetchAccess(path);
|
|
||||||
pathCache.set(key, view);
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
// has(node, verb) — check a per-entry verbs string for a single
|
|
||||||
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
|
|
||||||
// Transition shim: when node.verbs is absent, fall back to
|
|
||||||
// node.writable for 'w' so the legacy field keeps editor save
|
|
||||||
// buttons working on old listings — drop this fallback once every
|
|
||||||
// tool's loader sets node.verbs unconditionally.
|
|
||||||
function has(node, verb) {
|
|
||||||
if (!node) return false;
|
|
||||||
if (typeof node.verbs === 'string') {
|
|
||||||
return node.verbs.indexOf(verb) !== -1;
|
|
||||||
}
|
|
||||||
if (verb === 'w' && typeof node.writable === 'boolean') {
|
|
||||||
return node.writable;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
|
|
||||||
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
|
|
||||||
var VERB_LABELS = {
|
|
||||||
r: 'read',
|
|
||||||
w: 'write',
|
|
||||||
c: 'create',
|
|
||||||
d: 'delete',
|
|
||||||
a: 'edit access rules'
|
|
||||||
};
|
|
||||||
|
|
||||||
// handleForbidden(resp, opts) — render a 403 toast naming the
|
|
||||||
// missing verb. opts.path (optional) is the URL the failed request
|
|
||||||
// hit; when provided, the helper consults /.profile/access?path= to
|
|
||||||
// decide whether to offer an Elevate action. opts.context is an
|
|
||||||
// optional string prefix shown before the verb message ("Save",
|
|
||||||
// "Delete", etc.) — purely cosmetic.
|
|
||||||
//
|
|
||||||
// Best-effort: when the body isn't JSON or missing_verb is
|
|
||||||
// absent, falls back to a plain "Forbidden" toast. Returns the
|
|
||||||
// Promise so callers can await before chaining.
|
|
||||||
async function handleForbidden(resp, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
var missing = '';
|
|
||||||
try {
|
|
||||||
var body = await resp.clone().json();
|
|
||||||
if (body && typeof body.missing_verb === 'string') {
|
|
||||||
missing = body.missing_verb;
|
|
||||||
}
|
|
||||||
} catch (_e) { /* non-JSON body */ }
|
|
||||||
|
|
||||||
var prefix = opts.context ? (opts.context + ': ') : '';
|
|
||||||
var verbLabel = VERB_LABELS[missing] || missing || '';
|
|
||||||
var msg;
|
|
||||||
if (verbLabel) {
|
|
||||||
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
|
|
||||||
} else {
|
|
||||||
msg = prefix + 'Forbidden.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional elevate offer: only when the caller supplied a
|
|
||||||
// path AND the path-scoped access view reports an elevation
|
|
||||||
// grant covering the missing verb. Render as a clickable
|
|
||||||
// action appended to the toast message; clicking sets the
|
|
||||||
// elevation cookie and reloads, matching the header toggle.
|
|
||||||
var canOffer = false;
|
|
||||||
if (opts.path && missing) {
|
|
||||||
var view = await at(opts.path);
|
|
||||||
if (view && typeof view.path_can_elevate_grant === 'string'
|
|
||||||
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
|
|
||||||
canOffer = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var toastFn = (window.zddc && window.zddc.toast) || function () {};
|
|
||||||
var el = toastFn(msg, 'error', { durationMs: 8000 });
|
|
||||||
if (canOffer && el && el.appendChild) {
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'zddc-toast__action';
|
|
||||||
btn.textContent = 'Elevate';
|
|
||||||
btn.addEventListener('click', function (ev) {
|
|
||||||
ev.stopPropagation(); // don't dismiss the toast
|
|
||||||
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
|
|
||||||
window.zddc.elevation.setElevated(true);
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
el.appendChild(btn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
|
||||||
})();
|
|
||||||
|
|
||||||
// shared/icons.js — minimal outline SVG sprite for ZDDC tools.
|
// shared/icons.js — minimal outline SVG sprite for ZDDC tools.
|
||||||
//
|
//
|
||||||
// Vendored from Lucide (https://lucide.dev, ISC). Only the 16
|
// Vendored from Lucide (https://lucide.dev, ISC). Only the 16
|
||||||
|
|
@ -6797,26 +6607,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
// Server-computed write authority — true if the policy
|
// Server-computed write authority — true if the policy
|
||||||
// decider would allow a PUT for the calling principal.
|
// decider would allow a PUT for the calling principal.
|
||||||
// Absent / false means "save will 403"; preview editors
|
// Absent / false means "save will 403"; preview editors
|
||||||
// read this to mount in read-only mode. Superseded by
|
// read this to mount in read-only mode.
|
||||||
// verbs (below); kept in lockstep during the transition.
|
|
||||||
writable: !!e.writable,
|
writable: !!e.writable,
|
||||||
// Server-computed verb set: canonical "rwcda" subset the
|
|
||||||
// calling principal holds at this entry's URL. Per-entry
|
|
||||||
// gating in the context menu (Rename/Delete) reads this
|
|
||||||
// through zddc.cap.has(node, 'w'|'d').
|
|
||||||
//
|
|
||||||
// "rw…" — zddc-server emitted explicit grant.
|
|
||||||
// "" — zddc-server emitted explicit zero grant
|
|
||||||
// (rare; usually the entry would have been
|
|
||||||
// filtered before reaching the client).
|
|
||||||
// undefined — the server didn't emit a verbs field at
|
|
||||||
// all (Caddy or any non-zddc backend).
|
|
||||||
// cap.has and the events.js gates treat
|
|
||||||
// this as "verbs unknown" and skip the
|
|
||||||
// per-entry cascade gate; canMutate +
|
|
||||||
// whatever the server enforces on the
|
|
||||||
// actual PUT/DELETE still apply.
|
|
||||||
verbs: typeof e.verbs === 'string' ? e.verbs : undefined,
|
|
||||||
// FS-API specific (null in server mode):
|
// FS-API specific (null in server mode):
|
||||||
handle: null
|
handle: null
|
||||||
};
|
};
|
||||||
|
|
@ -7029,16 +6821,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
// whether to mount read-only. Dropping the field here
|
// whether to mount read-only. Dropping the field here
|
||||||
// silently makes every node read-only — the actual root
|
// silently makes every node read-only — the actual root
|
||||||
// cause behind "I'm admin but the editor says read-only".
|
// cause behind "I'm admin but the editor says read-only".
|
||||||
writable: !!raw.writable,
|
writable: !!raw.writable
|
||||||
// Server-computed verb set (canonical "rwcda" subset).
|
|
||||||
// Per-entry permission gating reads this via
|
|
||||||
// zddc.cap.has(node, verb). Three states:
|
|
||||||
// "rw…" — zddc-server explicit grant
|
|
||||||
// "" — zddc-server explicit zero grant
|
|
||||||
// undefined — Caddy / FS-API listings (no verbs field).
|
|
||||||
// Per-entry gates skip the cascade check
|
|
||||||
// and fall back to canMutate / writable.
|
|
||||||
verbs: typeof raw.verbs === 'string' ? raw.verbs : undefined
|
|
||||||
};
|
};
|
||||||
state.nodes.set(id, node);
|
state.nodes.set(id, node);
|
||||||
return node;
|
return node;
|
||||||
|
|
@ -8342,14 +8125,11 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
|
|
||||||
function canSave(node) {
|
function canSave(node) {
|
||||||
if (isZipMemberNode(node)) return false;
|
if (isZipMemberNode(node)) return false;
|
||||||
// Server-computed authority gate. The listing's verbs string
|
// Server-computed authority gate. The listing's `writable`
|
||||||
// tells us whether a PUT to this entry would be allowed —
|
// bit reflects what a PUT would do — false here means the
|
||||||
// false here means the file API would 403, so we mount in
|
// file API would 403 the save, so we mount in read-only
|
||||||
// read-only mode rather than letting the user type and lose
|
// mode rather than letting the user type and lose changes.
|
||||||
// changes. cap.has() falls back to node.writable for 'w'
|
if (node.url && window.app.state.source === 'server' && !node.writable) return false;
|
||||||
// when verbs is absent (offline FS-API listings).
|
|
||||||
if (node.url && window.app.state.source === 'server'
|
|
||||||
&& window.zddc.cap && !window.zddc.cap.has(node, 'w')) return false;
|
|
||||||
if (node.handle && typeof node.handle.createWritable === 'function') return true;
|
if (node.handle && typeof node.handle.createWritable === 'function') return true;
|
||||||
if (node.url && window.app.state.source === 'server') return true;
|
if (node.url && window.app.state.source === 'server') return true;
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -8885,15 +8665,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
// user home, canonical-folder virtuals) is just a tree
|
// user home, canonical-folder virtuals) is just a tree
|
||||||
// affordance, not a writable file.
|
// affordance, not a writable file.
|
||||||
if (node.virtual && node.name !== '.zddc') return false;
|
if (node.virtual && node.name !== '.zddc') return false;
|
||||||
// Server-computed authority gate. The virtual .zddc entry
|
// Server-computed authority gate. Mirrors the markdown editor's
|
||||||
// requires the admin verb 'a' (matches fileapi.go's
|
// check — listing's `writable` bit is the same decision the
|
||||||
// ActionAdmin gate at the .zddc URL); regular YAML files
|
// file API would reach on PUT.
|
||||||
// require write 'w'. cap.has falls back to node.writable for
|
if (node.url && window.app.state.source === 'server' && !node.writable) return false;
|
||||||
// 'w' when verbs is absent (offline FS-API listings).
|
|
||||||
if (node.url && window.app.state.source === 'server' && window.zddc.cap) {
|
|
||||||
var needed = node.name === '.zddc' ? 'a' : 'w';
|
|
||||||
if (!window.zddc.cap.has(node, needed)) return false;
|
|
||||||
}
|
|
||||||
if (node.handle && typeof node.handle.createWritable === 'function') return true;
|
if (node.handle && typeof node.handle.createWritable === 'function') return true;
|
||||||
if (node.url && window.app.state.source === 'server') return true;
|
if (node.url && window.app.state.source === 'server') return true;
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -11059,22 +10834,13 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
|
|
||||||
// stage.js — Stage and Unstage workflow modals.
|
// stage.js — Stage and Unstage workflow modals.
|
||||||
//
|
//
|
||||||
// After the layout reshape, working/ and staging/ live INSIDE each
|
// Stage: move a file from working/<…>/ into a transmittal folder under
|
||||||
// party folder: archive/<party>/working/<email>/<file> and
|
// staging/<…>/. Modal lists existing transmittal folders in staging/
|
||||||
// archive/<party>/staging/<batch>/<file>. Stage and Unstage are now
|
// plus a "New transmittal folder…" option that prompts for a ZDDC-
|
||||||
// per-party — the destination batch is always inside the SAME
|
// conforming name and mkdirs it before the move.
|
||||||
// party's staging slot. The party context is read from the source
|
|
||||||
// file's path.
|
|
||||||
//
|
//
|
||||||
// Stage: move a file from archive/<party>/working/<…> into a
|
// Unstage: move a file from staging/<transmittal>/ back to the user's
|
||||||
// transmittal folder under archive/<party>/staging/<…>. Modal lists
|
// working/<email>/ home (overridable).
|
||||||
// existing transmittal folders in the party's staging/ plus a "New
|
|
||||||
// transmittal folder…" option that prompts for a ZDDC-conforming
|
|
||||||
// name and mkdirs it before the move.
|
|
||||||
//
|
|
||||||
// Unstage: move a file from archive/<party>/staging/<transmittal>/
|
|
||||||
// back to the user's archive/<party>/working/<email>/ home
|
|
||||||
// (overridable).
|
|
||||||
//
|
//
|
||||||
// Both reuse the existing X-ZDDC-Op: move primitive — no new composite
|
// Both reuse the existing X-ZDDC-Op: move primitive — no new composite
|
||||||
// endpoint is needed; the client just orchestrates one POST per file
|
// endpoint is needed; the client just orchestrates one POST per file
|
||||||
|
|
@ -11094,37 +10860,32 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scope detection: path-shape, not cascade-content ──────────────
|
// ── Scope detection: path-shape, not cascade-content ──────────────
|
||||||
// A file is stageable if its path matches
|
// A file is stageable if its containing folder lives under
|
||||||
// /<project>/archive/<party>/working/<…>. Unstageable if it
|
// /<project>/working/<…>. Unstageable if it lives under
|
||||||
// matches /<project>/archive/<party>/staging/<transmittal>/<…>.
|
// /<project>/staging/<transmittal>/<…>. Both are path-shape
|
||||||
// Both are path-shape queries — content/ACL is enforced server-
|
// queries — content/ACL is enforced server-side.
|
||||||
// side.
|
|
||||||
|
|
||||||
// projectPartySlot returns { project, party, slot, rest } when
|
function projectAndSubtree(path) {
|
||||||
// path matches /<project>/archive/<party>/<slot>/<rest…>, or
|
|
||||||
// null on non-match.
|
|
||||||
function projectPartySlot(path) {
|
|
||||||
var rel = path.replace(/^\/+|\/+$/g, '').split('/');
|
var rel = path.replace(/^\/+|\/+$/g, '').split('/');
|
||||||
if (rel.length < 4) return null;
|
if (rel.length < 2) return null;
|
||||||
if (rel[1].toLowerCase() !== 'archive') return null;
|
return { project: rel[0], subtree: rel[1], rest: rel.slice(2) };
|
||||||
return { project: rel[0], party: rel[2], slot: rel[3], rest: rel.slice(4) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStageableFile(node) {
|
function isStageableFile(node) {
|
||||||
if (!node || node.isDir || node.virtual) return false;
|
if (!node || node.isDir || node.virtual) return false;
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
if (!tree) return false;
|
if (!tree) return false;
|
||||||
var p = projectPartySlot(tree.pathFor(node));
|
var p = projectAndSubtree(tree.pathFor(node));
|
||||||
return !!(p && p.slot === 'working' && p.rest.length >= 1);
|
return !!(p && p.subtree === 'working' && p.rest.length >= 1);
|
||||||
}
|
}
|
||||||
function isUnstageableFile(node) {
|
function isUnstageableFile(node) {
|
||||||
if (!node || node.isDir || node.virtual) return false;
|
if (!node || node.isDir || node.virtual) return false;
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
if (!tree) return false;
|
if (!tree) return false;
|
||||||
var p = projectPartySlot(tree.pathFor(node));
|
var p = projectAndSubtree(tree.pathFor(node));
|
||||||
// archive/<party>/staging/<transmittal-folder>/<file> — at
|
// staging/<transmittal-folder>/<file> — at least one folder
|
||||||
// least one folder segment between staging/ and the file.
|
// segment between staging/ and the file.
|
||||||
return !!(p && p.slot === 'staging' && p.rest.length >= 2);
|
return !!(p && p.subtree === 'staging' && p.rest.length >= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Server helpers ─────────────────────────────────────────────────
|
// ── Server helpers ─────────────────────────────────────────────────
|
||||||
|
|
@ -11142,9 +10903,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
return Array.isArray(data) ? data : [];
|
return Array.isArray(data) ? data : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStagingFolders(project, party) {
|
async function fetchStagingFolders(project) {
|
||||||
var entries = await listDir(
|
var entries = await listDir('/' + project + '/staging/');
|
||||||
'/' + project + '/archive/' + encodeURIComponent(party) + '/staging/');
|
|
||||||
return entries
|
return entries
|
||||||
.filter(function (e) { return e && e.isDir; })
|
.filter(function (e) { return e && e.isDir; })
|
||||||
.map(function (e) { return e.name; });
|
.map(function (e) { return e.name; });
|
||||||
|
|
@ -11330,15 +11090,14 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
if (!tree) return;
|
if (!tree) return;
|
||||||
var srcUrl = tree.pathFor(node);
|
var srcUrl = tree.pathFor(node);
|
||||||
var info = projectPartySlot(srcUrl);
|
var info = projectAndSubtree(srcUrl);
|
||||||
if (!info || info.slot !== 'working') {
|
if (!info || info.subtree !== 'working') {
|
||||||
status('Stage applies only to files under archive/<party>/working/.', 'error');
|
status('Stage applies only to files under working/.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var stagingBase = '/' + info.project + '/archive/' +
|
var stagingBase = '/' + info.project + '/staging/';
|
||||||
encodeURIComponent(info.party) + '/staging/';
|
|
||||||
var folders;
|
var folders;
|
||||||
try { folders = await fetchStagingFolders(info.project, info.party); }
|
try { folders = await fetchStagingFolders(info.project); }
|
||||||
catch (e) {
|
catch (e) {
|
||||||
status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error');
|
status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error');
|
||||||
return;
|
return;
|
||||||
|
|
@ -11365,21 +11124,20 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
status((e && e.message) || 'move failed', 'error');
|
status((e && e.message) || 'move failed', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/ — reload to see the move.', 'success');
|
status('Staged ' + node.name + ' → staging/' + choice.folderName + '/ — reload to see the move.', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function invokeUnstage(node) {
|
async function invokeUnstage(node) {
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
if (!tree) return;
|
if (!tree) return;
|
||||||
var srcUrl = tree.pathFor(node);
|
var srcUrl = tree.pathFor(node);
|
||||||
var info = projectPartySlot(srcUrl);
|
var info = projectAndSubtree(srcUrl);
|
||||||
if (!info || info.slot !== 'staging') {
|
if (!info || info.subtree !== 'staging') {
|
||||||
status('Unstage applies only to files under archive/<party>/staging/.', 'error');
|
status('Unstage applies only to files under staging/.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var email = await fetchSelfEmail();
|
var email = await fetchSelfEmail();
|
||||||
var defaultTarget = '/' + info.project + '/archive/' +
|
var defaultTarget = '/' + info.project + '/working/' + (email || '') + '/';
|
||||||
encodeURIComponent(info.party) + '/working/' + (email || '') + '/';
|
|
||||||
var choice;
|
var choice;
|
||||||
try {
|
try {
|
||||||
choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget });
|
choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget });
|
||||||
|
|
@ -12448,55 +12206,16 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
|
||||||
// ── Rename + Delete (the permission-gated pair) ──
|
// ── Rename + Delete (the permission-gated pair) ──
|
||||||
//
|
|
||||||
// Two gates compose: canMutate() rules out un-writable
|
|
||||||
// sources (offline FS-API without a handle, zip members,
|
|
||||||
// virtual placeholders) and — when the listing carries
|
|
||||||
// server-cascade verbs — zddc.cap.has(node, verb) applies
|
|
||||||
// the per-entry ACL. The verbs gate is server-mode only;
|
|
||||||
// file:// FS-API and plain Caddy listings have no verbs
|
|
||||||
// field, so we fall back to canMutate alone (FS-API
|
|
||||||
// enforces locally; Caddy has no PUT/DELETE either way).
|
|
||||||
// Server-side ACL still has the final say on the actual
|
|
||||||
// PUT/DELETE if a stale client tries the action.
|
|
||||||
{
|
{
|
||||||
label: 'Rename…',
|
label: 'Rename…',
|
||||||
disabled: function (c) {
|
disabled: function (c) { return !canMutate(c); },
|
||||||
if (!canMutate(c)) return true;
|
|
||||||
if (!serverMode || !window.zddc.cap) return false;
|
|
||||||
// verbs===undefined → Caddy or other non-zddc
|
|
||||||
// server, no cascade signal to gate on. verbs===""
|
|
||||||
// is zddc-server's explicit zero grant; still
|
|
||||||
// gate (disable). verbs==="rw…" → check the bit.
|
|
||||||
if (typeof c.node.verbs !== 'string') return false;
|
|
||||||
return !window.zddc.cap.has(c.node, 'w');
|
|
||||||
},
|
|
||||||
tooltip: function (c) {
|
|
||||||
if (!serverMode || !canMutate(c)) return '';
|
|
||||||
if (!window.zddc.cap) return '';
|
|
||||||
if (typeof c.node.verbs !== 'string') return '';
|
|
||||||
if (window.zddc.cap.has(c.node, 'w')) return '';
|
|
||||||
return "You don't have write access to this item.";
|
|
||||||
},
|
|
||||||
action: function (c) { renameNode(c.node); }
|
action: function (c) { renameNode(c.node); }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete…',
|
label: 'Delete…',
|
||||||
icon: '🗑',
|
icon: '🗑',
|
||||||
danger: true,
|
danger: true,
|
||||||
disabled: function (c) {
|
disabled: function (c) { return !canMutate(c); },
|
||||||
if (!canMutate(c)) return true;
|
|
||||||
if (!serverMode || !window.zddc.cap) return false;
|
|
||||||
if (typeof c.node.verbs !== 'string') return false;
|
|
||||||
return !window.zddc.cap.has(c.node, 'd');
|
|
||||||
},
|
|
||||||
tooltip: function (c) {
|
|
||||||
if (!serverMode || !canMutate(c)) return '';
|
|
||||||
if (!window.zddc.cap) return '';
|
|
||||||
if (typeof c.node.verbs !== 'string') return '';
|
|
||||||
if (window.zddc.cap.has(c.node, 'd')) return '';
|
|
||||||
return "You don't have delete access to this item.";
|
|
||||||
},
|
|
||||||
action: function (c) { deleteNode(c.node); }
|
action: function (c) { deleteNode(c.node); }
|
||||||
},
|
},
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
|
|
||||||
|
|
@ -836,25 +836,6 @@ body.help-open .app-header {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline action button appended to a toast by zddc.cap.handleForbidden
|
|
||||||
when an Elevate path is offered. Stops click propagation on its own
|
|
||||||
so clicking the button doesn't also dismiss the toast. */
|
|
||||||
.zddc-toast__action {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 0.75rem;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
background: var(--accent, var(--text));
|
|
||||||
color: var(--bg);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.zddc-toast__action:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||||
Renders only for users with admin scope (handled by elevation.js;
|
Renders only for users with admin scope (handled by elevation.js;
|
||||||
the placeholder is `.hidden` by default). When visible, sits left
|
the placeholder is `.hidden` by default). When visible, sits left
|
||||||
|
|
@ -1793,7 +1774,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Classifier</span>
|
<span class="app-header__title">ZDDC Classifier</span>
|
||||||
<span class="build-timestamp">v0.0.21</span>
|
<span class="build-timestamp">v0.0.19</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
@ -10013,170 +9994,6 @@ X.B(E,Y);return E}return J}())
|
||||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/cap.js — client-side capability helpers for permission gating.
|
|
||||||
//
|
|
||||||
// Three small helpers, exposed under window.zddc.cap, that wrap the
|
|
||||||
// server's verbs / /.profile/access?path / 403 missing_verb surface:
|
|
||||||
//
|
|
||||||
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
|
|
||||||
// /.profile/access?path=<urlpath> and
|
|
||||||
// memoises per-path for the session.
|
|
||||||
// Used by tools to gate top-of-page
|
|
||||||
// affordances (Publish, +Add row,
|
|
||||||
// +New folder) on PathVerbs.
|
|
||||||
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
|
|
||||||
// "rwcda"-subset) for the listed verb.
|
|
||||||
// Transition: falls back to
|
|
||||||
// node.writable for 'w' when verbs
|
|
||||||
// is absent, so the legacy field still
|
|
||||||
// drives gating on old listings.
|
|
||||||
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
|
|
||||||
// parses the JSON body for
|
|
||||||
// missing_verb and renders a toast.
|
|
||||||
// Offers "Elevate" when the path's
|
|
||||||
// /.profile/access?path= reports a
|
|
||||||
// path_can_elevate_grant covering the
|
|
||||||
// missing verb.
|
|
||||||
//
|
|
||||||
// Tools using this module must concat shared/cap.js AFTER shared/
|
|
||||||
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
|
||||||
if (window.zddc.cap) return;
|
|
||||||
|
|
||||||
var pathCache = new Map(); // path → AccessView (or null sentinel)
|
|
||||||
|
|
||||||
async function fetchAccess(path) {
|
|
||||||
// file:// pages have no server to fetch /.profile/access from;
|
|
||||||
// calling fetch() there logs a browser-level error before our
|
|
||||||
// catch even runs. Short-circuit so offline tools (browse on
|
|
||||||
// a picked folder, form opened from a file URL) silently
|
|
||||||
// degrade to "no path-scoped info, fall back to existing
|
|
||||||
// gating signals".
|
|
||||||
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var url = '/.profile/access';
|
|
||||||
if (path) url += '?path=' + encodeURIComponent(path);
|
|
||||||
var resp = await fetch(url, {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
cache: 'no-cache'
|
|
||||||
});
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
return await resp.json();
|
|
||||||
} catch (_e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// at(path) — fetch path-scoped access view, memoised per path
|
|
||||||
// within the page session. Cache is page-scoped: any elevation
|
|
||||||
// toggle forces a hard reload (see shared/elevation.js), which
|
|
||||||
// resets the cache so stale-after-elevation isn't a concern. Pass
|
|
||||||
// null/undefined for the global view (no ?path=).
|
|
||||||
async function at(path) {
|
|
||||||
var key = path || '';
|
|
||||||
if (pathCache.has(key)) return pathCache.get(key);
|
|
||||||
var view = await fetchAccess(path);
|
|
||||||
pathCache.set(key, view);
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
// has(node, verb) — check a per-entry verbs string for a single
|
|
||||||
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
|
|
||||||
// Transition shim: when node.verbs is absent, fall back to
|
|
||||||
// node.writable for 'w' so the legacy field keeps editor save
|
|
||||||
// buttons working on old listings — drop this fallback once every
|
|
||||||
// tool's loader sets node.verbs unconditionally.
|
|
||||||
function has(node, verb) {
|
|
||||||
if (!node) return false;
|
|
||||||
if (typeof node.verbs === 'string') {
|
|
||||||
return node.verbs.indexOf(verb) !== -1;
|
|
||||||
}
|
|
||||||
if (verb === 'w' && typeof node.writable === 'boolean') {
|
|
||||||
return node.writable;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
|
|
||||||
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
|
|
||||||
var VERB_LABELS = {
|
|
||||||
r: 'read',
|
|
||||||
w: 'write',
|
|
||||||
c: 'create',
|
|
||||||
d: 'delete',
|
|
||||||
a: 'edit access rules'
|
|
||||||
};
|
|
||||||
|
|
||||||
// handleForbidden(resp, opts) — render a 403 toast naming the
|
|
||||||
// missing verb. opts.path (optional) is the URL the failed request
|
|
||||||
// hit; when provided, the helper consults /.profile/access?path= to
|
|
||||||
// decide whether to offer an Elevate action. opts.context is an
|
|
||||||
// optional string prefix shown before the verb message ("Save",
|
|
||||||
// "Delete", etc.) — purely cosmetic.
|
|
||||||
//
|
|
||||||
// Best-effort: when the body isn't JSON or missing_verb is
|
|
||||||
// absent, falls back to a plain "Forbidden" toast. Returns the
|
|
||||||
// Promise so callers can await before chaining.
|
|
||||||
async function handleForbidden(resp, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
var missing = '';
|
|
||||||
try {
|
|
||||||
var body = await resp.clone().json();
|
|
||||||
if (body && typeof body.missing_verb === 'string') {
|
|
||||||
missing = body.missing_verb;
|
|
||||||
}
|
|
||||||
} catch (_e) { /* non-JSON body */ }
|
|
||||||
|
|
||||||
var prefix = opts.context ? (opts.context + ': ') : '';
|
|
||||||
var verbLabel = VERB_LABELS[missing] || missing || '';
|
|
||||||
var msg;
|
|
||||||
if (verbLabel) {
|
|
||||||
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
|
|
||||||
} else {
|
|
||||||
msg = prefix + 'Forbidden.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional elevate offer: only when the caller supplied a
|
|
||||||
// path AND the path-scoped access view reports an elevation
|
|
||||||
// grant covering the missing verb. Render as a clickable
|
|
||||||
// action appended to the toast message; clicking sets the
|
|
||||||
// elevation cookie and reloads, matching the header toggle.
|
|
||||||
var canOffer = false;
|
|
||||||
if (opts.path && missing) {
|
|
||||||
var view = await at(opts.path);
|
|
||||||
if (view && typeof view.path_can_elevate_grant === 'string'
|
|
||||||
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
|
|
||||||
canOffer = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var toastFn = (window.zddc && window.zddc.toast) || function () {};
|
|
||||||
var el = toastFn(msg, 'error', { durationMs: 8000 });
|
|
||||||
if (canOffer && el && el.appendChild) {
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'zddc-toast__action';
|
|
||||||
btn.textContent = 'Elevate';
|
|
||||||
btn.addEventListener('click', function (ev) {
|
|
||||||
ev.stopPropagation(); // don't dismiss the toast
|
|
||||||
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
|
|
||||||
window.zddc.elevation.setElevated(true);
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
el.appendChild(btn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
|
||||||
})();
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -836,25 +836,6 @@ body.help-open .app-header {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline action button appended to a toast by zddc.cap.handleForbidden
|
|
||||||
when an Elevate path is offered. Stops click propagation on its own
|
|
||||||
so clicking the button doesn't also dismiss the toast. */
|
|
||||||
.zddc-toast__action {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 0.75rem;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
background: var(--accent, var(--text));
|
|
||||||
color: var(--bg);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.zddc-toast__action:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||||
Renders only for users with admin scope (handled by elevation.js;
|
Renders only for users with admin scope (handled by elevation.js;
|
||||||
the placeholder is `.hidden` by default). When visible, sits left
|
the placeholder is `.hidden` by default). When visible, sits left
|
||||||
|
|
@ -1536,7 +1517,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC</span>
|
<span class="app-header__title">ZDDC</span>
|
||||||
<span class="build-timestamp">v0.0.21</span>
|
<span class="build-timestamp">v0.0.19</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -2695,170 +2676,6 @@ body {
|
||||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/cap.js — client-side capability helpers for permission gating.
|
|
||||||
//
|
|
||||||
// Three small helpers, exposed under window.zddc.cap, that wrap the
|
|
||||||
// server's verbs / /.profile/access?path / 403 missing_verb surface:
|
|
||||||
//
|
|
||||||
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
|
|
||||||
// /.profile/access?path=<urlpath> and
|
|
||||||
// memoises per-path for the session.
|
|
||||||
// Used by tools to gate top-of-page
|
|
||||||
// affordances (Publish, +Add row,
|
|
||||||
// +New folder) on PathVerbs.
|
|
||||||
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
|
|
||||||
// "rwcda"-subset) for the listed verb.
|
|
||||||
// Transition: falls back to
|
|
||||||
// node.writable for 'w' when verbs
|
|
||||||
// is absent, so the legacy field still
|
|
||||||
// drives gating on old listings.
|
|
||||||
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
|
|
||||||
// parses the JSON body for
|
|
||||||
// missing_verb and renders a toast.
|
|
||||||
// Offers "Elevate" when the path's
|
|
||||||
// /.profile/access?path= reports a
|
|
||||||
// path_can_elevate_grant covering the
|
|
||||||
// missing verb.
|
|
||||||
//
|
|
||||||
// Tools using this module must concat shared/cap.js AFTER shared/
|
|
||||||
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
|
||||||
if (window.zddc.cap) return;
|
|
||||||
|
|
||||||
var pathCache = new Map(); // path → AccessView (or null sentinel)
|
|
||||||
|
|
||||||
async function fetchAccess(path) {
|
|
||||||
// file:// pages have no server to fetch /.profile/access from;
|
|
||||||
// calling fetch() there logs a browser-level error before our
|
|
||||||
// catch even runs. Short-circuit so offline tools (browse on
|
|
||||||
// a picked folder, form opened from a file URL) silently
|
|
||||||
// degrade to "no path-scoped info, fall back to existing
|
|
||||||
// gating signals".
|
|
||||||
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var url = '/.profile/access';
|
|
||||||
if (path) url += '?path=' + encodeURIComponent(path);
|
|
||||||
var resp = await fetch(url, {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
cache: 'no-cache'
|
|
||||||
});
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
return await resp.json();
|
|
||||||
} catch (_e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// at(path) — fetch path-scoped access view, memoised per path
|
|
||||||
// within the page session. Cache is page-scoped: any elevation
|
|
||||||
// toggle forces a hard reload (see shared/elevation.js), which
|
|
||||||
// resets the cache so stale-after-elevation isn't a concern. Pass
|
|
||||||
// null/undefined for the global view (no ?path=).
|
|
||||||
async function at(path) {
|
|
||||||
var key = path || '';
|
|
||||||
if (pathCache.has(key)) return pathCache.get(key);
|
|
||||||
var view = await fetchAccess(path);
|
|
||||||
pathCache.set(key, view);
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
// has(node, verb) — check a per-entry verbs string for a single
|
|
||||||
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
|
|
||||||
// Transition shim: when node.verbs is absent, fall back to
|
|
||||||
// node.writable for 'w' so the legacy field keeps editor save
|
|
||||||
// buttons working on old listings — drop this fallback once every
|
|
||||||
// tool's loader sets node.verbs unconditionally.
|
|
||||||
function has(node, verb) {
|
|
||||||
if (!node) return false;
|
|
||||||
if (typeof node.verbs === 'string') {
|
|
||||||
return node.verbs.indexOf(verb) !== -1;
|
|
||||||
}
|
|
||||||
if (verb === 'w' && typeof node.writable === 'boolean') {
|
|
||||||
return node.writable;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
|
|
||||||
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
|
|
||||||
var VERB_LABELS = {
|
|
||||||
r: 'read',
|
|
||||||
w: 'write',
|
|
||||||
c: 'create',
|
|
||||||
d: 'delete',
|
|
||||||
a: 'edit access rules'
|
|
||||||
};
|
|
||||||
|
|
||||||
// handleForbidden(resp, opts) — render a 403 toast naming the
|
|
||||||
// missing verb. opts.path (optional) is the URL the failed request
|
|
||||||
// hit; when provided, the helper consults /.profile/access?path= to
|
|
||||||
// decide whether to offer an Elevate action. opts.context is an
|
|
||||||
// optional string prefix shown before the verb message ("Save",
|
|
||||||
// "Delete", etc.) — purely cosmetic.
|
|
||||||
//
|
|
||||||
// Best-effort: when the body isn't JSON or missing_verb is
|
|
||||||
// absent, falls back to a plain "Forbidden" toast. Returns the
|
|
||||||
// Promise so callers can await before chaining.
|
|
||||||
async function handleForbidden(resp, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
var missing = '';
|
|
||||||
try {
|
|
||||||
var body = await resp.clone().json();
|
|
||||||
if (body && typeof body.missing_verb === 'string') {
|
|
||||||
missing = body.missing_verb;
|
|
||||||
}
|
|
||||||
} catch (_e) { /* non-JSON body */ }
|
|
||||||
|
|
||||||
var prefix = opts.context ? (opts.context + ': ') : '';
|
|
||||||
var verbLabel = VERB_LABELS[missing] || missing || '';
|
|
||||||
var msg;
|
|
||||||
if (verbLabel) {
|
|
||||||
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
|
|
||||||
} else {
|
|
||||||
msg = prefix + 'Forbidden.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional elevate offer: only when the caller supplied a
|
|
||||||
// path AND the path-scoped access view reports an elevation
|
|
||||||
// grant covering the missing verb. Render as a clickable
|
|
||||||
// action appended to the toast message; clicking sets the
|
|
||||||
// elevation cookie and reloads, matching the header toggle.
|
|
||||||
var canOffer = false;
|
|
||||||
if (opts.path && missing) {
|
|
||||||
var view = await at(opts.path);
|
|
||||||
if (view && typeof view.path_can_elevate_grant === 'string'
|
|
||||||
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
|
|
||||||
canOffer = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var toastFn = (window.zddc && window.zddc.toast) || function () {};
|
|
||||||
var el = toastFn(msg, 'error', { durationMs: 8000 });
|
|
||||||
if (canOffer && el && el.appendChild) {
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'zddc-toast__action';
|
|
||||||
btn.textContent = 'Elevate';
|
|
||||||
btn.addEventListener('click', function (ev) {
|
|
||||||
ev.stopPropagation(); // don't dismiss the toast
|
|
||||||
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
|
|
||||||
window.zddc.elevation.setElevated(true);
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
el.appendChild(btn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
|
||||||
})();
|
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
// ZDDC landing page — project picker.
|
// ZDDC landing page — project picker.
|
||||||
|
|
|
||||||
|
|
@ -840,25 +840,6 @@ body.help-open .app-header {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline action button appended to a toast by zddc.cap.handleForbidden
|
|
||||||
when an Elevate path is offered. Stops click propagation on its own
|
|
||||||
so clicking the button doesn't also dismiss the toast. */
|
|
||||||
.zddc-toast__action {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 0.75rem;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
background: var(--accent, var(--text));
|
|
||||||
color: var(--bg);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.zddc-toast__action:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||||
Renders only for users with admin scope (handled by elevation.js;
|
Renders only for users with admin scope (handled by elevation.js;
|
||||||
the placeholder is `.hidden` by default). When visible, sits left
|
the placeholder is `.hidden` by default). When visible, sits left
|
||||||
|
|
@ -2635,7 +2616,7 @@ dialog.modal--narrow {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<span class="app-header__title">ZDDC Transmittal</span>
|
||||||
<span class="build-timestamp">v0.0.21</span>
|
<span class="build-timestamp">v0.0.19</span>
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||||
<!-- Publish split-button (Transmittal-specific primary action;
|
<!-- Publish split-button (Transmittal-specific primary action;
|
||||||
|
|
@ -13437,170 +13418,6 @@ X.B(E,Y);return E}return J}())
|
||||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/cap.js — client-side capability helpers for permission gating.
|
|
||||||
//
|
|
||||||
// Three small helpers, exposed under window.zddc.cap, that wrap the
|
|
||||||
// server's verbs / /.profile/access?path / 403 missing_verb surface:
|
|
||||||
//
|
|
||||||
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
|
|
||||||
// /.profile/access?path=<urlpath> and
|
|
||||||
// memoises per-path for the session.
|
|
||||||
// Used by tools to gate top-of-page
|
|
||||||
// affordances (Publish, +Add row,
|
|
||||||
// +New folder) on PathVerbs.
|
|
||||||
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
|
|
||||||
// "rwcda"-subset) for the listed verb.
|
|
||||||
// Transition: falls back to
|
|
||||||
// node.writable for 'w' when verbs
|
|
||||||
// is absent, so the legacy field still
|
|
||||||
// drives gating on old listings.
|
|
||||||
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
|
|
||||||
// parses the JSON body for
|
|
||||||
// missing_verb and renders a toast.
|
|
||||||
// Offers "Elevate" when the path's
|
|
||||||
// /.profile/access?path= reports a
|
|
||||||
// path_can_elevate_grant covering the
|
|
||||||
// missing verb.
|
|
||||||
//
|
|
||||||
// Tools using this module must concat shared/cap.js AFTER shared/
|
|
||||||
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
|
||||||
if (window.zddc.cap) return;
|
|
||||||
|
|
||||||
var pathCache = new Map(); // path → AccessView (or null sentinel)
|
|
||||||
|
|
||||||
async function fetchAccess(path) {
|
|
||||||
// file:// pages have no server to fetch /.profile/access from;
|
|
||||||
// calling fetch() there logs a browser-level error before our
|
|
||||||
// catch even runs. Short-circuit so offline tools (browse on
|
|
||||||
// a picked folder, form opened from a file URL) silently
|
|
||||||
// degrade to "no path-scoped info, fall back to existing
|
|
||||||
// gating signals".
|
|
||||||
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var url = '/.profile/access';
|
|
||||||
if (path) url += '?path=' + encodeURIComponent(path);
|
|
||||||
var resp = await fetch(url, {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
cache: 'no-cache'
|
|
||||||
});
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
return await resp.json();
|
|
||||||
} catch (_e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// at(path) — fetch path-scoped access view, memoised per path
|
|
||||||
// within the page session. Cache is page-scoped: any elevation
|
|
||||||
// toggle forces a hard reload (see shared/elevation.js), which
|
|
||||||
// resets the cache so stale-after-elevation isn't a concern. Pass
|
|
||||||
// null/undefined for the global view (no ?path=).
|
|
||||||
async function at(path) {
|
|
||||||
var key = path || '';
|
|
||||||
if (pathCache.has(key)) return pathCache.get(key);
|
|
||||||
var view = await fetchAccess(path);
|
|
||||||
pathCache.set(key, view);
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
// has(node, verb) — check a per-entry verbs string for a single
|
|
||||||
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
|
|
||||||
// Transition shim: when node.verbs is absent, fall back to
|
|
||||||
// node.writable for 'w' so the legacy field keeps editor save
|
|
||||||
// buttons working on old listings — drop this fallback once every
|
|
||||||
// tool's loader sets node.verbs unconditionally.
|
|
||||||
function has(node, verb) {
|
|
||||||
if (!node) return false;
|
|
||||||
if (typeof node.verbs === 'string') {
|
|
||||||
return node.verbs.indexOf(verb) !== -1;
|
|
||||||
}
|
|
||||||
if (verb === 'w' && typeof node.writable === 'boolean') {
|
|
||||||
return node.writable;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
|
|
||||||
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
|
|
||||||
var VERB_LABELS = {
|
|
||||||
r: 'read',
|
|
||||||
w: 'write',
|
|
||||||
c: 'create',
|
|
||||||
d: 'delete',
|
|
||||||
a: 'edit access rules'
|
|
||||||
};
|
|
||||||
|
|
||||||
// handleForbidden(resp, opts) — render a 403 toast naming the
|
|
||||||
// missing verb. opts.path (optional) is the URL the failed request
|
|
||||||
// hit; when provided, the helper consults /.profile/access?path= to
|
|
||||||
// decide whether to offer an Elevate action. opts.context is an
|
|
||||||
// optional string prefix shown before the verb message ("Save",
|
|
||||||
// "Delete", etc.) — purely cosmetic.
|
|
||||||
//
|
|
||||||
// Best-effort: when the body isn't JSON or missing_verb is
|
|
||||||
// absent, falls back to a plain "Forbidden" toast. Returns the
|
|
||||||
// Promise so callers can await before chaining.
|
|
||||||
async function handleForbidden(resp, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
var missing = '';
|
|
||||||
try {
|
|
||||||
var body = await resp.clone().json();
|
|
||||||
if (body && typeof body.missing_verb === 'string') {
|
|
||||||
missing = body.missing_verb;
|
|
||||||
}
|
|
||||||
} catch (_e) { /* non-JSON body */ }
|
|
||||||
|
|
||||||
var prefix = opts.context ? (opts.context + ': ') : '';
|
|
||||||
var verbLabel = VERB_LABELS[missing] || missing || '';
|
|
||||||
var msg;
|
|
||||||
if (verbLabel) {
|
|
||||||
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
|
|
||||||
} else {
|
|
||||||
msg = prefix + 'Forbidden.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional elevate offer: only when the caller supplied a
|
|
||||||
// path AND the path-scoped access view reports an elevation
|
|
||||||
// grant covering the missing verb. Render as a clickable
|
|
||||||
// action appended to the toast message; clicking sets the
|
|
||||||
// elevation cookie and reloads, matching the header toggle.
|
|
||||||
var canOffer = false;
|
|
||||||
if (opts.path && missing) {
|
|
||||||
var view = await at(opts.path);
|
|
||||||
if (view && typeof view.path_can_elevate_grant === 'string'
|
|
||||||
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
|
|
||||||
canOffer = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var toastFn = (window.zddc && window.zddc.toast) || function () {};
|
|
||||||
var el = toastFn(msg, 'error', { durationMs: 8000 });
|
|
||||||
if (canOffer && el && el.appendChild) {
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'zddc-toast__action';
|
|
||||||
btn.textContent = 'Elevate';
|
|
||||||
btn.addEventListener('click', function (ev) {
|
|
||||||
ev.stopPropagation(); // don't dismiss the toast
|
|
||||||
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
|
|
||||||
window.zddc.elevation.setElevated(true);
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
el.appendChild(btn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
|
||||||
})();
|
|
||||||
|
|
||||||
(function (app) {
|
(function (app) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
archive=v0.0.21
|
archive=v0.0.19
|
||||||
transmittal=v0.0.21
|
transmittal=v0.0.19
|
||||||
classifier=v0.0.21
|
classifier=v0.0.19
|
||||||
landing=v0.0.21
|
landing=v0.0.19
|
||||||
form=v0.0.21
|
form=v0.0.19
|
||||||
tables=v0.0.21
|
tables=v0.0.19
|
||||||
browse=v0.0.21
|
browse=v0.0.19
|
||||||
|
|
|
||||||
|
|
@ -758,12 +758,11 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
// unfenced so ancestor grants still cascade through.
|
// unfenced so ancestor grants still cascade through.
|
||||||
if email != "" {
|
if email != "" {
|
||||||
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
|
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
|
||||||
roles := zddc.AutoOwnRolesAt(cfg.Root, abs)
|
|
||||||
var werr error
|
var werr error
|
||||||
if zddc.AutoOwnFencedAt(cfg.Root, abs) {
|
if zddc.AutoOwnFencedAt(cfg.Root, abs) {
|
||||||
werr = zddc.WriteAutoOwnZddcFenced(abs, email, roles)
|
werr = zddc.WriteAutoOwnZddcFenced(abs, email)
|
||||||
} else {
|
} else {
|
||||||
werr = zddc.WriteAutoOwnZddc(abs, email, roles)
|
werr = zddc.WriteAutoOwnZddc(abs, email)
|
||||||
}
|
}
|
||||||
if werr != nil {
|
if werr != nil {
|
||||||
slog.Warn("auto-own .zddc write failed", "path", abs, "err", werr)
|
slog.Warn("auto-own .zddc write failed", "path", abs, "err", werr)
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,11 @@ func planReviewSetup(t *testing.T) (config.Config, func(target, email string, bo
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
||||||
// Root .zddc grants alice root-admin AND adds her to the
|
// Root .zddc grants alice subtree-admin everywhere AND sets the
|
||||||
// document_controller role. The root-admin status + elevated
|
// document_controller role so the cascade's reviewing/+staging/
|
||||||
// principal (set on the request below) is what carries her past
|
// admin grants resolve to her. The role membership also confers
|
||||||
// Plan Review's ActionAdmin checks — DCs are no longer subtree-
|
// `c` authority on received/ via the WORM list in the defaults,
|
||||||
// admin by default; their party-level `a` verb comes from the
|
// which Plan Review's pre-flight requires.
|
||||||
// auto-own .zddc that ensure.go writes when they mkdir
|
|
||||||
// archive/<party>/ (carrying auto_own_roles: [document_controller]
|
|
||||||
// from the defaults). This fixture uses root-admin to keep the
|
|
||||||
// test self-contained without scaffolding a party folder; the
|
|
||||||
// non-root-admin DC path is covered by the standardroles tests.
|
|
||||||
mustWriteHelper(t, filepath.Join(root, ".zddc"),
|
mustWriteHelper(t, filepath.Join(root, ".zddc"),
|
||||||
"admins:\n - alice@example.com\n"+
|
"admins:\n - alice@example.com\n"+
|
||||||
"roles:\n document_controller:\n members: [alice@example.com]\n")
|
"roles:\n document_controller:\n members: [alice@example.com]\n")
|
||||||
|
|
|
||||||
|
|
@ -145,12 +145,11 @@ func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWrit
|
||||||
// auto_own in defaults.zddc.yaml, so the unfenced creator grant
|
// auto_own in defaults.zddc.yaml, so the unfenced creator grant
|
||||||
// fires here exactly as it would for a manual mkdir.
|
// fires here exactly as it would for a manual mkdir.
|
||||||
if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) {
|
if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) {
|
||||||
roles := zddc.AutoOwnRolesAt(cfg.Root, partyAbs)
|
|
||||||
var werr error
|
var werr error
|
||||||
if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) {
|
if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) {
|
||||||
werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email, roles)
|
werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email)
|
||||||
} else {
|
} else {
|
||||||
werr = zddc.WriteAutoOwnZddc(partyAbs, email, roles)
|
werr = zddc.WriteAutoOwnZddc(partyAbs, email)
|
||||||
}
|
}
|
||||||
if werr != nil {
|
if werr != nil {
|
||||||
slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr)
|
slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr)
|
||||||
|
|
|
||||||
|
|
@ -836,25 +836,6 @@ body.help-open .app-header {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline action button appended to a toast by zddc.cap.handleForbidden
|
|
||||||
when an Elevate path is offered. Stops click propagation on its own
|
|
||||||
so clicking the button doesn't also dismiss the toast. */
|
|
||||||
.zddc-toast__action {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 0.75rem;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
background: var(--accent, var(--text));
|
|
||||||
color: var(--bg);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.zddc-toast__action:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||||
Renders only for users with admin scope (handled by elevation.js;
|
Renders only for users with admin scope (handled by elevation.js;
|
||||||
the placeholder is `.hidden` by default). When visible, sits left
|
the placeholder is `.hidden` by default). When visible, sits left
|
||||||
|
|
@ -1534,7 +1515,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp">v0.0.21</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -2971,170 +2952,6 @@ body.is-elevated::after {
|
||||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/cap.js — client-side capability helpers for permission gating.
|
|
||||||
//
|
|
||||||
// Three small helpers, exposed under window.zddc.cap, that wrap the
|
|
||||||
// server's verbs / /.profile/access?path / 403 missing_verb surface:
|
|
||||||
//
|
|
||||||
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
|
|
||||||
// /.profile/access?path=<urlpath> and
|
|
||||||
// memoises per-path for the session.
|
|
||||||
// Used by tools to gate top-of-page
|
|
||||||
// affordances (Publish, +Add row,
|
|
||||||
// +New folder) on PathVerbs.
|
|
||||||
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
|
|
||||||
// "rwcda"-subset) for the listed verb.
|
|
||||||
// Transition: falls back to
|
|
||||||
// node.writable for 'w' when verbs
|
|
||||||
// is absent, so the legacy field still
|
|
||||||
// drives gating on old listings.
|
|
||||||
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
|
|
||||||
// parses the JSON body for
|
|
||||||
// missing_verb and renders a toast.
|
|
||||||
// Offers "Elevate" when the path's
|
|
||||||
// /.profile/access?path= reports a
|
|
||||||
// path_can_elevate_grant covering the
|
|
||||||
// missing verb.
|
|
||||||
//
|
|
||||||
// Tools using this module must concat shared/cap.js AFTER shared/
|
|
||||||
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
|
||||||
if (window.zddc.cap) return;
|
|
||||||
|
|
||||||
var pathCache = new Map(); // path → AccessView (or null sentinel)
|
|
||||||
|
|
||||||
async function fetchAccess(path) {
|
|
||||||
// file:// pages have no server to fetch /.profile/access from;
|
|
||||||
// calling fetch() there logs a browser-level error before our
|
|
||||||
// catch even runs. Short-circuit so offline tools (browse on
|
|
||||||
// a picked folder, form opened from a file URL) silently
|
|
||||||
// degrade to "no path-scoped info, fall back to existing
|
|
||||||
// gating signals".
|
|
||||||
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var url = '/.profile/access';
|
|
||||||
if (path) url += '?path=' + encodeURIComponent(path);
|
|
||||||
var resp = await fetch(url, {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
cache: 'no-cache'
|
|
||||||
});
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
return await resp.json();
|
|
||||||
} catch (_e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// at(path) — fetch path-scoped access view, memoised per path
|
|
||||||
// within the page session. Cache is page-scoped: any elevation
|
|
||||||
// toggle forces a hard reload (see shared/elevation.js), which
|
|
||||||
// resets the cache so stale-after-elevation isn't a concern. Pass
|
|
||||||
// null/undefined for the global view (no ?path=).
|
|
||||||
async function at(path) {
|
|
||||||
var key = path || '';
|
|
||||||
if (pathCache.has(key)) return pathCache.get(key);
|
|
||||||
var view = await fetchAccess(path);
|
|
||||||
pathCache.set(key, view);
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
// has(node, verb) — check a per-entry verbs string for a single
|
|
||||||
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
|
|
||||||
// Transition shim: when node.verbs is absent, fall back to
|
|
||||||
// node.writable for 'w' so the legacy field keeps editor save
|
|
||||||
// buttons working on old listings — drop this fallback once every
|
|
||||||
// tool's loader sets node.verbs unconditionally.
|
|
||||||
function has(node, verb) {
|
|
||||||
if (!node) return false;
|
|
||||||
if (typeof node.verbs === 'string') {
|
|
||||||
return node.verbs.indexOf(verb) !== -1;
|
|
||||||
}
|
|
||||||
if (verb === 'w' && typeof node.writable === 'boolean') {
|
|
||||||
return node.writable;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
|
|
||||||
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
|
|
||||||
var VERB_LABELS = {
|
|
||||||
r: 'read',
|
|
||||||
w: 'write',
|
|
||||||
c: 'create',
|
|
||||||
d: 'delete',
|
|
||||||
a: 'edit access rules'
|
|
||||||
};
|
|
||||||
|
|
||||||
// handleForbidden(resp, opts) — render a 403 toast naming the
|
|
||||||
// missing verb. opts.path (optional) is the URL the failed request
|
|
||||||
// hit; when provided, the helper consults /.profile/access?path= to
|
|
||||||
// decide whether to offer an Elevate action. opts.context is an
|
|
||||||
// optional string prefix shown before the verb message ("Save",
|
|
||||||
// "Delete", etc.) — purely cosmetic.
|
|
||||||
//
|
|
||||||
// Best-effort: when the body isn't JSON or missing_verb is
|
|
||||||
// absent, falls back to a plain "Forbidden" toast. Returns the
|
|
||||||
// Promise so callers can await before chaining.
|
|
||||||
async function handleForbidden(resp, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
var missing = '';
|
|
||||||
try {
|
|
||||||
var body = await resp.clone().json();
|
|
||||||
if (body && typeof body.missing_verb === 'string') {
|
|
||||||
missing = body.missing_verb;
|
|
||||||
}
|
|
||||||
} catch (_e) { /* non-JSON body */ }
|
|
||||||
|
|
||||||
var prefix = opts.context ? (opts.context + ': ') : '';
|
|
||||||
var verbLabel = VERB_LABELS[missing] || missing || '';
|
|
||||||
var msg;
|
|
||||||
if (verbLabel) {
|
|
||||||
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
|
|
||||||
} else {
|
|
||||||
msg = prefix + 'Forbidden.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional elevate offer: only when the caller supplied a
|
|
||||||
// path AND the path-scoped access view reports an elevation
|
|
||||||
// grant covering the missing verb. Render as a clickable
|
|
||||||
// action appended to the toast message; clicking sets the
|
|
||||||
// elevation cookie and reloads, matching the header toggle.
|
|
||||||
var canOffer = false;
|
|
||||||
if (opts.path && missing) {
|
|
||||||
var view = await at(opts.path);
|
|
||||||
if (view && typeof view.path_can_elevate_grant === 'string'
|
|
||||||
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
|
|
||||||
canOffer = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var toastFn = (window.zddc && window.zddc.toast) || function () {};
|
|
||||||
var el = toastFn(msg, 'error', { durationMs: 8000 });
|
|
||||||
if (canOffer && el && el.appendChild) {
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'zddc-toast__action';
|
|
||||||
btn.textContent = 'Elevate';
|
|
||||||
btn.addEventListener('click', function (ev) {
|
|
||||||
ev.stopPropagation(); // don't dismiss the toast
|
|
||||||
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
|
|
||||||
window.zddc.elevation.setElevated(true);
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
el.appendChild(btn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
|
||||||
})();
|
|
||||||
|
|
||||||
// shared/context-menu.js — generic context-menu framework exposed on
|
// shared/context-menu.js — generic context-menu framework exposed on
|
||||||
// window.zddc.menu. Built so every ZDDC tool can drop a right-click
|
// window.zddc.menu. Built so every ZDDC tool can drop a right-click
|
||||||
// menu (or any programmatically-opened menu) onto its UI without
|
// menu (or any programmatically-opened menu) onto its UI without
|
||||||
|
|
@ -3146,11 +2963,8 @@ body.is-elevated::after {
|
||||||
//
|
//
|
||||||
// `items` is an array (or a function returning an array, evaluated
|
// `items` is an array (or a function returning an array, evaluated
|
||||||
// against `context` at open-time). Each entry is one of:
|
// against `context` at open-time). Each entry is one of:
|
||||||
// { label, action, icon?, accel?, disabled?, visible?, danger?, tooltip? }
|
// { label, action, icon?, accel?, disabled?, visible?, danger? }
|
||||||
// — a normal menu item; `action(ctx)` fires on click/Enter.
|
// — a normal menu item; `action(ctx)` fires on click/Enter.
|
||||||
// `tooltip` (string or fn(ctx)) sets the row's title attribute —
|
|
||||||
// useful for explaining WHY a disabled item is unavailable
|
|
||||||
// ("You don't have write access here", etc.).
|
|
||||||
// { label, checked, action, ... }
|
// { label, checked, action, ... }
|
||||||
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
|
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
|
||||||
// a ✓ in the gutter when truthy.
|
// a ✓ in the gutter when truthy.
|
||||||
|
|
@ -3161,10 +2975,10 @@ body.is-elevated::after {
|
||||||
// are collapsed automatically so callers can build items
|
// are collapsed automatically so callers can build items
|
||||||
// conditionally without managing dividers.
|
// conditionally without managing dividers.
|
||||||
//
|
//
|
||||||
// Any of `label`, `checked`, `visible`, `disabled`, `tooltip`, and
|
// Any of `label`, `checked`, `visible`, `disabled`, and `items` may
|
||||||
// `items` may be a function — each is invoked with the context object
|
// be a function — each is invoked with the context object so callers
|
||||||
// so callers can render fully context-aware menus from a single
|
// can render fully context-aware menus from a single declarative
|
||||||
// declarative config.
|
// config.
|
||||||
//
|
//
|
||||||
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
|
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
|
||||||
// submenu, ArrowLeft / Escape backs up one level (or closes if
|
// submenu, ArrowLeft / Escape backs up one level (or closes if
|
||||||
|
|
@ -3286,10 +3100,6 @@ body.is-elevated::after {
|
||||||
row.classList.add('is-disabled');
|
row.classList.add('is-disabled');
|
||||||
row.setAttribute('aria-disabled', 'true');
|
row.setAttribute('aria-disabled', 'true');
|
||||||
}
|
}
|
||||||
if ('tooltip' in item) {
|
|
||||||
var tip = resolve(item.tooltip, ctx);
|
|
||||||
if (tip) row.title = String(tip);
|
|
||||||
}
|
|
||||||
row.setAttribute('role',
|
row.setAttribute('role',
|
||||||
hasSub ? 'menuitem'
|
hasSub ? 'menuitem'
|
||||||
: (isToggle ? 'menuitemcheckbox' : 'menuitem'));
|
: (isToggle ? 'menuitemcheckbox' : 'menuitem'));
|
||||||
|
|
@ -5630,17 +5440,6 @@ body.is-elevated::after {
|
||||||
return { status: 'invalid', errors: errs };
|
return { status: 'invalid', errors: errs };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.status === 403) {
|
|
||||||
setRowState(rowId, 'errored');
|
|
||||||
if (window.zddc && window.zddc.cap) {
|
|
||||||
window.zddc.cap.handleForbidden(resp, {
|
|
||||||
context: 'Save row',
|
|
||||||
path: location.pathname
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { status: 'forbidden' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other status — generic error.
|
// Other status — generic error.
|
||||||
console.warn('[tables] save returned', resp.status);
|
console.warn('[tables] save returned', resp.status);
|
||||||
setRowState(rowId, 'errored');
|
setRowState(rowId, 'errored');
|
||||||
|
|
@ -5720,17 +5519,6 @@ body.is-elevated::after {
|
||||||
return { status: 'invalid', errors: errs };
|
return { status: 'invalid', errors: errs };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.status === 403) {
|
|
||||||
setRowState(rowId, 'errored');
|
|
||||||
if (window.zddc && window.zddc.cap) {
|
|
||||||
window.zddc.cap.handleForbidden(resp, {
|
|
||||||
context: 'Add row',
|
|
||||||
path: location.pathname
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { status: 'forbidden' };
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn('[tables] createRow returned', resp.status);
|
console.warn('[tables] createRow returned', resp.status);
|
||||||
setRowState(rowId, 'errored');
|
setRowState(rowId, 'errored');
|
||||||
return { status: 'http-error', code: resp.status };
|
return { status: 'http-error', code: resp.status };
|
||||||
|
|
@ -6686,27 +6474,15 @@ body.is-elevated::after {
|
||||||
// clicked a header link, the URL bar, etc. without moving to
|
// clicked a header link, the URL bar, etc. without moving to
|
||||||
// another row first). focusout fires for cell-to-cell moves
|
// another row first). focusout fires for cell-to-cell moves
|
||||||
// too — relatedTarget being outside #table-root distinguishes.
|
// too — relatedTarget being outside #table-root distinguishes.
|
||||||
//
|
|
||||||
// Deferred to next tick (setTimeout 0): the editor's commit
|
|
||||||
// path tears down its input element and then refocuses the
|
|
||||||
// owning cell. The remove fires focusout BEFORE the refocus
|
|
||||||
// runs, with relatedTarget=null (body briefly), so the naive
|
|
||||||
// sync check would mis-detect a "left the grid" event and
|
|
||||||
// fire flushAll redundantly alongside the selection-change
|
|
||||||
// save. Checking document.activeElement on the next tick
|
|
||||||
// gives the refocus time to settle.
|
|
||||||
const tableRoot = document.getElementById('table-root');
|
const tableRoot = document.getElementById('table-root');
|
||||||
if (tableRoot) {
|
if (tableRoot) {
|
||||||
tableRoot.addEventListener('focusout', function (ev) {
|
tableRoot.addEventListener('focusout', function (ev) {
|
||||||
const next = ev.relatedTarget;
|
const next = ev.relatedTarget;
|
||||||
if (next && tableRoot.contains(next)) return;
|
if (next && tableRoot.contains(next)) return;
|
||||||
setTimeout(function () {
|
|
||||||
if (tableRoot.contains(document.activeElement)) return;
|
|
||||||
const save = app.modules.save;
|
const save = app.modules.save;
|
||||||
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
|
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
|
||||||
save.flushAll();
|
save.flushAll();
|
||||||
}
|
}
|
||||||
}, 0);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6751,33 +6527,6 @@ body.is-elevated::after {
|
||||||
addRowBtn.addEventListener('keydown', function (ev) {
|
addRowBtn.addEventListener('keydown', function (ev) {
|
||||||
if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev);
|
if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Permission gate: fetch the path-scoped verbs for the
|
|
||||||
// current directory and disable + Add row when the
|
|
||||||
// cascade denies create. Async — the button shows up
|
|
||||||
// optimistically and disables a tick later if the
|
|
||||||
// server says no, which is the same race window every
|
|
||||||
// path-scoped fetch has. Server still gates the POST,
|
|
||||||
// so the worst case is a 403 toast on click.
|
|
||||||
if (window.zddc && window.zddc.cap) {
|
|
||||||
window.zddc.cap.at(location.pathname).then(function (view) {
|
|
||||||
if (!view) return;
|
|
||||||
var verbs = view.path_verbs || '';
|
|
||||||
if (verbs.indexOf('c') === -1) {
|
|
||||||
addRowBtn.classList.add('is-disabled');
|
|
||||||
addRowBtn.setAttribute('aria-disabled', 'true');
|
|
||||||
addRowBtn.title = "You don't have create access in this folder.";
|
|
||||||
// Swallow clicks so the no-op feedback is the
|
|
||||||
// tooltip, not a 403 toast on submission.
|
|
||||||
addRowBtn.addEventListener('click', function (ev) {
|
|
||||||
if (addRowBtn.classList.contains('is-disabled')) {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7627,12 +7376,6 @@ body.is-elevated::after {
|
||||||
showStatus('Please correct the errors below.', 'error');
|
showStatus('Please correct the errors below.', 'error');
|
||||||
} else if (res.status === 403) {
|
} else if (res.status === 403) {
|
||||||
showStatus('You are not allowed to submit here.', 'error');
|
showStatus('You are not allowed to submit here.', 'error');
|
||||||
if (window.zddc && window.zddc.cap) {
|
|
||||||
window.zddc.cap.handleForbidden(res, {
|
|
||||||
context: 'Submit',
|
|
||||||
path: app.context.submitUrl
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (res.status === 409) {
|
} else if (res.status === 409) {
|
||||||
showStatus('A submission with this filename already exists.', 'error');
|
showStatus('A submission with this filename already exists.', 'error');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -7733,29 +7476,6 @@ body.is-elevated::after {
|
||||||
const submitBtn = document.getElementById('submit-btn');
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
if (submitBtn) {
|
if (submitBtn) {
|
||||||
submitBtn.addEventListener('click', app.modules.post.submit);
|
submitBtn.addEventListener('click', app.modules.post.submit);
|
||||||
// Pre-flight gate: hide Submit when the cascade denies
|
|
||||||
// create at the submission directory. Server still
|
|
||||||
// enforces on POST — this just avoids dangling an
|
|
||||||
// affordance that would 403. Submission directory is the
|
|
||||||
// parent of submitUrl; fall back to the page URL when
|
|
||||||
// submitUrl is absent (file:// / no-context mode).
|
|
||||||
if (window.zddc && window.zddc.cap && app.context && app.context.submitUrl) {
|
|
||||||
const subUrl = app.context.submitUrl;
|
|
||||||
const dir = subUrl.replace(/\/[^\/]*$/, '/') || subUrl;
|
|
||||||
window.zddc.cap.at(dir).then(function (view) {
|
|
||||||
if (!view) return;
|
|
||||||
const verbs = view.path_verbs || '';
|
|
||||||
if (verbs.indexOf('c') === -1) {
|
|
||||||
submitBtn.hidden = true;
|
|
||||||
const status = document.getElementById('form-status');
|
|
||||||
if (status) {
|
|
||||||
status.textContent = "You don't have permission to submit here.";
|
|
||||||
status.hidden = false;
|
|
||||||
status.classList.add('is-error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
|
|
@ -36,17 +35,13 @@ func IsZddcFileRequest(urlPath string) bool {
|
||||||
// user who can read the directory can read its .zddc.
|
// user who can read the directory can read its .zddc.
|
||||||
// On-disk: if <dir>/.zddc exists, its bytes are returned verbatim
|
// On-disk: if <dir>/.zddc exists, its bytes are returned verbatim
|
||||||
// with Content-Type: application/yaml.
|
// with Content-Type: application/yaml.
|
||||||
// Virtual: if it does not exist, the body is the cascade's
|
// Virtual: if it does not exist, a synthetic body is returned with a
|
||||||
// leaf-level ZddcFile (what defaults.zddc.yaml's paths:
|
// cascade summary so the operator can see what rules are
|
||||||
// tree declares for THIS exact directory, plus any
|
// effective at this depth. The synthetic body is clearly
|
||||||
// virtual contributions threaded through by the walker)
|
// marked with comments — PUT-saving its bytes back to the
|
||||||
// marshalled as YAML. A header comment names the source
|
// same URL (through the file API) materialises a real file.
|
||||||
// and points at ?effective=1 for the composed view. The
|
// The virtual response sets X-ZDDC-Source: virtual so the
|
||||||
// virtual body is itself valid YAML — PUT-saving it back
|
// client can distinguish.
|
||||||
// (with or without edits) through the file API
|
|
||||||
// materialises a real on-disk override carrying exactly
|
|
||||||
// the bytes the user saved. The response sets
|
|
||||||
// X-ZDDC-Source: virtual:zddc so clients can distinguish.
|
|
||||||
func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
decider := DeciderFromContext(r)
|
decider := DeciderFromContext(r)
|
||||||
|
|
||||||
|
|
@ -91,16 +86,6 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ?effective=1 branch: return the composed cascade view as JSON.
|
|
||||||
// Distinct from the .zddc file itself — the YAML body is "what's
|
|
||||||
// defined at this level" (source of truth); this is "what's
|
|
||||||
// effective after merging every ancestor" (inspection only, not
|
|
||||||
// PUT-saveable as a .zddc).
|
|
||||||
if r.URL.Query().Get("effective") == "1" {
|
|
||||||
serveEffectiveZddc(cfg, dirURL, chain, w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
zddcPath := filepath.Join(abs, ".zddc")
|
zddcPath := filepath.Join(abs, ".zddc")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||||
|
|
@ -118,15 +103,9 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// No file on disk → render the cascade's leaf level as YAML.
|
// No file on disk → synthetic placeholder body with a cascade
|
||||||
// What the user sees is the embedded defaults' declared shape
|
// summary so the user can see what's actually effective here.
|
||||||
// for this exact path; PUT-saving it back materialises an
|
body := renderVirtualZddc(cfg.Root, abs, chain)
|
||||||
// on-disk override verbatim.
|
|
||||||
body, err := renderVirtualZddc(chain)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("X-ZDDC-Source", "virtual:zddc")
|
w.Header().Set("X-ZDDC-Source", "virtual:zddc")
|
||||||
if r.Method == http.MethodHead {
|
if r.Method == http.MethodHead {
|
||||||
return
|
return
|
||||||
|
|
@ -134,168 +113,75 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(body))
|
_, _ = w.Write([]byte(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderVirtualZddc produces a YAML body for a directory that has no
|
// renderVirtualZddc produces a self-describing YAML placeholder for a
|
||||||
// .zddc on disk. The body is the cascade's leaf-level ZddcFile —
|
// directory that has no .zddc on disk. The body is valid YAML (parses
|
||||||
// i.e. what defaults.zddc.yaml's paths: tree declares for this exact
|
// to an empty document) so a downstream YAML tool isn't fazed; the
|
||||||
// directory, plus any contributions the walker threaded through. The
|
// commentary lives in comments. Each ancestor's contribution is
|
||||||
// goal is to expose the embedded defaults' source of truth: a new
|
// summarised so the reader sees exactly what's effective at this
|
||||||
// user opening the virtual .zddc here sees, in the same yaml shape
|
// depth.
|
||||||
// they would write themselves, what behavior is currently declared
|
func renderVirtualZddc(fsRoot, dirAbs string, chain zddc.PolicyChain) string {
|
||||||
// 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]
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("# Virtual .zddc — no file on disk at this directory.\n")
|
fmt.Fprintf(&b, "# Virtual .zddc — no file on disk at this directory yet.\n")
|
||||||
b.WriteString("# The content below is what the embedded defaults\n")
|
fmt.Fprintf(&b, "# Rules below are inherited from ancestors. Edit + save\n")
|
||||||
b.WriteString("# (defaults.zddc.yaml's paths: tree) declare for this\n")
|
fmt.Fprintf(&b, "# (PUT) through the YAML editor in browse (admin-only)\n")
|
||||||
b.WriteString("# exact path. Edit and save through the YAML editor in\n")
|
fmt.Fprintf(&b, "# to override at this level — the save materialises a\n")
|
||||||
b.WriteString("# browse to materialise a real .zddc here carrying your\n")
|
fmt.Fprintf(&b, "# real file here.\n")
|
||||||
b.WriteString("# changes; the bytes you save become the override\n")
|
fmt.Fprintf(&b, "#\n")
|
||||||
b.WriteString("# verbatim (no merge, no synthesis — .zddc files drive\n")
|
fmt.Fprintf(&b, "# Effective cascade at %s:\n", urlPathOf(fsRoot, dirAbs))
|
||||||
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) {
|
// Walk the levels from root down. Each ZddcFile in chain.Levels
|
||||||
b.WriteString("#\n")
|
// corresponds to one ancestor (root, .../, ..., dirAbs). Show only
|
||||||
b.WriteString("# No rules declared at this exact level — every rule\n")
|
// the levels that contributed something non-empty.
|
||||||
b.WriteString("# currently in effect here is inherited from ancestors.\n")
|
dirs := chainDirs(fsRoot, dirAbs)
|
||||||
b.WriteString("{}\n")
|
any := false
|
||||||
return b.String(), nil
|
for i, lvl := range chain.Levels {
|
||||||
|
var levelDir string
|
||||||
|
if i < len(dirs) {
|
||||||
|
levelDir = dirs[i]
|
||||||
|
} else {
|
||||||
|
levelDir = fsRoot
|
||||||
}
|
}
|
||||||
|
entry := summariseLevel(lvl)
|
||||||
body, err := yaml.Marshal(&leaf)
|
if entry == "" {
|
||||||
if err != nil {
|
continue
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
b.WriteByte('\n')
|
any = true
|
||||||
b.Write(body)
|
fmt.Fprintf(&b, "#\n# from %s/.zddc:\n%s",
|
||||||
return b.String(), nil
|
urlPathOf(fsRoot, levelDir), entry)
|
||||||
|
}
|
||||||
|
if !any {
|
||||||
|
fmt.Fprintf(&b, "# (no ancestor .zddc contributes any rule)\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "\n# --- placeholder body (empty) ---\n")
|
||||||
|
fmt.Fprintf(&b, "{}\n")
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// effectiveSourceView is the wire shape for one entry in the
|
// summariseLevel produces a comment block describing one .zddc level's
|
||||||
// `sources` array of the ?effective=1 response. Level matches
|
// non-empty contributions (title, acl, admins, apps, tables). Empty
|
||||||
// zddc.SourceEntry.Level (-1 = embedded baseline, 0+ = chain index);
|
// levels return "" so the caller can skip them.
|
||||||
// URL is the directory URL of that level (or "<embedded>" for the
|
func summariseLevel(lvl zddc.ZddcFile) string {
|
||||||
// baseline); Contributed lists the top-level fields the level
|
var b strings.Builder
|
||||||
// declared.
|
if lvl.Title != "" {
|
||||||
type effectiveSourceView struct {
|
fmt.Fprintf(&b, "# title: %q\n", lvl.Title)
|
||||||
Level int `json:"level"`
|
}
|
||||||
URL string `json:"url"`
|
if len(lvl.ACL.Permissions) > 0 {
|
||||||
Contributed []string `json:"contributed,omitempty"`
|
fmt.Fprintf(&b, "# acl.permissions: %v\n", lvl.ACL.Permissions)
|
||||||
}
|
}
|
||||||
|
if len(lvl.Admins) > 0 {
|
||||||
// effectiveZddcView is the wire shape for the ?effective=1 response.
|
fmt.Fprintf(&b, "# admins: %v\n", lvl.Admins)
|
||||||
// Merged is the composed cascade as a ZddcFile (same struct shape the
|
}
|
||||||
// editor consumes for an on-disk .zddc; client-side renderers can
|
if len(lvl.Apps) > 0 {
|
||||||
// reuse the same parser). Sources lists per-level contributions so
|
fmt.Fprintf(&b, "# apps:\n")
|
||||||
// the user can trace any value back to its origin without re-walking
|
for k, v := range lvl.Apps {
|
||||||
// the cascade by hand.
|
fmt.Fprintf(&b, "# %s: %s\n", k, v)
|
||||||
type effectiveZddcView struct {
|
}
|
||||||
URLPath string `json:"url_path"`
|
}
|
||||||
Merged zddc.ZddcFile `json:"merged"`
|
if len(lvl.Tables) > 0 {
|
||||||
Sources []effectiveSourceView `json:"sources"`
|
fmt.Fprintf(&b, "# tables:\n")
|
||||||
}
|
for k, v := range lvl.Tables {
|
||||||
|
fmt.Fprintf(&b, "# %s: %s\n", k, v)
|
||||||
// serveEffectiveZddc writes the JSON composed-cascade view for the
|
}
|
||||||
// .zddc URL. Same ACL as the YAML view (already enforced by the
|
}
|
||||||
// caller).
|
return b.String()
|
||||||
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,7 +2,6 @@ package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -69,20 +68,10 @@ func TestServeZddcFile_ExistingFile(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestServeZddcFile_VirtualDefault — when no .zddc is on disk at the
|
|
||||||
// requested directory, the body is the cascade's leaf-level ZddcFile
|
|
||||||
// marshalled as YAML, prefixed by a header comment explaining what
|
|
||||||
// the file is and pointing at ?effective=1 for the composed view.
|
|
||||||
//
|
|
||||||
// At /Project/.zddc with no on-disk file, the leaf is the embedded
|
|
||||||
// defaults' paths.* contribution — i.e. the project-scoped baseline
|
|
||||||
// (project_team: r, observer: r, document_controller: rw) plus the
|
|
||||||
// canonical paths: tree (archive, working, staging, reviewing, …).
|
|
||||||
// Asserts a few load-bearing markers; the full content is the
|
|
||||||
// `defaults.zddc.yaml` source-of-truth, which lives under
|
|
||||||
// zddc/internal/zddc and is parsed at every cascade walk.
|
|
||||||
func TestServeZddcFile_VirtualDefault(t *testing.T) {
|
func TestServeZddcFile_VirtualDefault(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||||
|
"title: bootstrap\nacl:\n permissions:\n \"*\": rwcda\n")
|
||||||
// Directory exists but has no .zddc.
|
// Directory exists but has no .zddc.
|
||||||
subDir := filepath.Join(root, "Project")
|
subDir := filepath.Join(root, "Project")
|
||||||
if err := os.Mkdir(subDir, 0o755); err != nil {
|
if err := os.Mkdir(subDir, 0o755); err != nil {
|
||||||
|
|
@ -104,273 +93,15 @@ func TestServeZddcFile_VirtualDefault(t *testing.T) {
|
||||||
}
|
}
|
||||||
body := rec.Body.String()
|
body := rec.Body.String()
|
||||||
if !strings.Contains(body, "Virtual .zddc") {
|
if !strings.Contains(body, "Virtual .zddc") {
|
||||||
t.Errorf("body missing virtual header comment: %q", body)
|
t.Errorf("body missing virtual marker: %q", body)
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, "?effective=1") {
|
// Should show the root's title from the cascade.
|
||||||
t.Errorf("body missing pointer to the composed-view query: %q", body)
|
if !strings.Contains(body, "bootstrap") {
|
||||||
}
|
t.Errorf("body missing root cascade summary: %q", body)
|
||||||
// The embedded defaults declare project_team: r and
|
|
||||||
// observer: r at paths.*. Confirm both surface so the user
|
|
||||||
// sees the project-scoped baseline.
|
|
||||||
if !strings.Contains(body, "project_team: r") {
|
|
||||||
t.Errorf("body missing project_team grant from embedded defaults: %q", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "observer: r") {
|
|
||||||
t.Errorf("body missing observer grant from embedded defaults: %q", body)
|
|
||||||
}
|
|
||||||
// The paths: subtree below should include archive (the only
|
|
||||||
// physical project-root child) and the virtual aggregators.
|
|
||||||
if !strings.Contains(body, "archive:") {
|
|
||||||
t.Errorf("body missing archive subtree: %q", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServeZddcFile_VirtualEmpty — at a directory the embedded
|
|
||||||
// defaults' paths: tree does NOT cover, the body collapses to the
|
|
||||||
// header comment + an empty-document placeholder ({}). The user
|
|
||||||
// sees "no rules declared at this exact level".
|
|
||||||
func TestServeZddcFile_VirtualEmpty(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
// /Project/random-subfolder/ is not declared in the embedded
|
|
||||||
// defaults' paths tree (paths.* matches the project name, but
|
|
||||||
// no child path matches "random-subfolder").
|
|
||||||
deep := filepath.Join(root, "Project", "random-subfolder")
|
|
||||||
if err := os.MkdirAll(deep, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(root)
|
|
||||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/Project/random-subfolder/.zddc", nil)
|
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
ServeZddcFile(cfg, rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, "Virtual .zddc") {
|
|
||||||
t.Errorf("body missing virtual header: %q", body)
|
|
||||||
}
|
}
|
||||||
|
// Should parse as valid YAML (empty document or {} at the end).
|
||||||
if !strings.Contains(body, "{}") {
|
if !strings.Contains(body, "{}") {
|
||||||
t.Errorf("undeclared-level body should end in {}: %q", body)
|
t.Errorf("body missing placeholder body: %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,141 +272,6 @@ func (chain PolicyChain) EffectiveRecordRule(basename string) (string, RecordRul
|
||||||
return pattern, merged, true
|
return pattern, merged, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceEntry names one cascade contribution to an EffectiveZddc
|
|
||||||
// composition. Level -1 is the embedded defaults baseline (chain.
|
|
||||||
// Embedded); levels 0+ index into chain.Levels (root→leaf). Contributed
|
|
||||||
// lists the top-level ZddcFile field names this level supplied a non-
|
|
||||||
// zero value for — used by inspection clients to answer "where does
|
|
||||||
// this value come from?" without re-walking the cascade.
|
|
||||||
type SourceEntry struct {
|
|
||||||
Level int `json:"level"`
|
|
||||||
Contributed []string `json:"contributed,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// EffectiveZddc composes the cascade into a single ZddcFile by walking
|
|
||||||
// chain.Embedded then chain.Levels[VisibleStart..] through mergeOverlay,
|
|
||||||
// and folding the cross-level Roles union (via lookupRoleMembers) into
|
|
||||||
// merged.Roles so the result reflects the same role membership the
|
|
||||||
// runtime ACL evaluator sees.
|
|
||||||
//
|
|
||||||
// Returned alongside is a per-source list of which top-level fields
|
|
||||||
// each contributing level declared. Caller maps SourceEntry.Level to a
|
|
||||||
// URL (-1 = embedded baseline; 0..len(chain.Levels)-1 = dirs along the
|
|
||||||
// walk from fsRoot to the requested directory).
|
|
||||||
//
|
|
||||||
// Returns the zero ZddcFile + nil sources when the chain is empty.
|
|
||||||
// Used by the ?effective=1 query on /.zddc — distinct from the .zddc
|
|
||||||
// file itself, which serves only what's defined at the leaf level.
|
|
||||||
func EffectiveZddc(chain PolicyChain) (ZddcFile, []SourceEntry) {
|
|
||||||
if len(chain.Levels) == 0 {
|
|
||||||
return ZddcFile{}, nil
|
|
||||||
}
|
|
||||||
sources := make([]SourceEntry, 0, len(chain.Levels)+1)
|
|
||||||
var merged ZddcFile
|
|
||||||
|
|
||||||
// Embedded baseline (skipped when an inherit:false fence dropped
|
|
||||||
// it; cascade.go zeroes chain.Embedded in that case).
|
|
||||||
if c := nonZeroZddcFields(chain.Embedded); len(c) > 0 {
|
|
||||||
merged = mergeOverlay(merged, chain.Embedded)
|
|
||||||
sources = append(sources, SourceEntry{Level: -1, Contributed: c})
|
|
||||||
}
|
|
||||||
|
|
||||||
leafIdx := len(chain.Levels) - 1
|
|
||||||
floor := chain.VisibleStart(leafIdx)
|
|
||||||
for i := floor; i <= leafIdx; i++ {
|
|
||||||
lvl := chain.Levels[i]
|
|
||||||
if c := nonZeroZddcFields(lvl); len(c) > 0 {
|
|
||||||
merged = mergeOverlay(merged, lvl)
|
|
||||||
sources = append(sources, SourceEntry{Level: i, Contributed: c})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Roles: mergeOverlay does per-level name-keyed replacement, but
|
|
||||||
// the runtime evaluator unions members across levels via
|
|
||||||
// lookupRoleMembers (handling reset:true and the embedded
|
|
||||||
// baseline). Re-resolve every role name reachable in the visible
|
|
||||||
// chain so merged.Roles matches what ACL evaluation sees.
|
|
||||||
roleNames := collectRoleNames(chain, floor, leafIdx)
|
|
||||||
if len(roleNames) > 0 {
|
|
||||||
out := make(map[string]Role, len(roleNames))
|
|
||||||
for _, name := range roleNames {
|
|
||||||
members, defined := lookupRoleMembers(chain, leafIdx, name)
|
|
||||||
if !defined {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[name] = Role{Members: members}
|
|
||||||
}
|
|
||||||
merged.Roles = out
|
|
||||||
} else {
|
|
||||||
merged.Roles = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged, sources
|
|
||||||
}
|
|
||||||
|
|
||||||
// nonZeroZddcFields returns the names of top-level ZddcFile fields zf
|
|
||||||
// has populated. Field names match the yaml tags (so "acl" not "ACL").
|
|
||||||
// Used to populate SourceEntry.Contributed.
|
|
||||||
func nonZeroZddcFields(zf ZddcFile) []string {
|
|
||||||
var out []string
|
|
||||||
add := func(name string, cond bool) {
|
|
||||||
if cond {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
add("title", zf.Title != "")
|
|
||||||
add("acl", len(zf.ACL.Permissions) > 0 || zf.ACL.Inherit != nil)
|
|
||||||
add("admins", len(zf.Admins) > 0)
|
|
||||||
add("apps", len(zf.Apps) > 0)
|
|
||||||
add("apps_pubkey", zf.AppsPubKey != "")
|
|
||||||
add("tables", len(zf.Tables) > 0)
|
|
||||||
add("display", len(zf.Display) > 0)
|
|
||||||
add("convert", zf.Convert != nil)
|
|
||||||
add("roles", len(zf.Roles) > 0)
|
|
||||||
add("created_by", zf.CreatedBy != "")
|
|
||||||
add("default_tool", zf.DefaultTool != "")
|
|
||||||
add("dir_tool", zf.DirTool != "")
|
|
||||||
add("auto_own", zf.AutoOwn != nil)
|
|
||||||
add("auto_own_fenced", zf.AutoOwnFenced != nil)
|
|
||||||
add("virtual", zf.Virtual != nil)
|
|
||||||
add("drop_target", zf.DropTarget != nil)
|
|
||||||
add("worm", zf.Worm != nil)
|
|
||||||
add("available_tools", len(zf.AvailableTools) > 0)
|
|
||||||
add("received_path", zf.ReceivedPath != "")
|
|
||||||
add("planned_review_date", zf.PlannedReviewDate != "")
|
|
||||||
add("planned_response_date", zf.PlannedResponseDate != "")
|
|
||||||
add("field_codes", len(zf.FieldCodes) > 0)
|
|
||||||
add("records", len(zf.Records) > 0)
|
|
||||||
add("paths", len(zf.Paths) > 0)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectRoleNames returns every role name that has a definition in
|
|
||||||
// any visible level (or the embedded baseline). Used by EffectiveZddc
|
|
||||||
// to know which roles to resolve via lookupRoleMembers — without it
|
|
||||||
// we'd miss roles declared only at an ancestor not directly merged at
|
|
||||||
// the leaf level (since per-level mergeOverlay replaces Roles by key,
|
|
||||||
// not by union).
|
|
||||||
func collectRoleNames(chain PolicyChain, floor, leafIdx int) []string {
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
for i := floor; i <= leafIdx; i++ {
|
|
||||||
for name := range chain.Levels[i].Roles {
|
|
||||||
seen[name] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for name := range chain.Embedded.Roles {
|
|
||||||
seen[name] = struct{}{}
|
|
||||||
}
|
|
||||||
if len(seen) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(seen))
|
|
||||||
for name := range seen {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvalidateCache removes the cached policy for dirPath and all descendants.
|
// InvalidateCache removes the cached policy for dirPath and all descendants.
|
||||||
func InvalidateCache(dirPath string) {
|
func InvalidateCache(dirPath string) {
|
||||||
dirPath = filepath.Clean(dirPath)
|
dirPath = filepath.Clean(dirPath)
|
||||||
|
|
|
||||||
|
|
@ -34,53 +34,22 @@ acl:
|
||||||
# the reset are then excluded.
|
# the reset are then excluded.
|
||||||
#
|
#
|
||||||
# document_controller — the people who file into
|
# document_controller — the people who file into
|
||||||
# archive/<party>/received/ and issued/ (WORM zones). They get:
|
# archive/<party>/received/ and issued/ (WORM zones). They get
|
||||||
# - rwcda at every archive/<party>/ via the role grant written
|
# read+write-once-create there (via the worm: lists below) and
|
||||||
# into each party's auto-own .zddc (auto_own_roles below).
|
# read/write elsewhere in a project, plus subtree-admin of the
|
||||||
# Cascade carries rwcda down to descendants by default.
|
# per-party working/ + staging/ + reviewing/ so they can stand up
|
||||||
# - read+write-once-create at received/issued via the worm:
|
# and manage drafting/transmittal/review folders. They are NOT
|
||||||
# lists (the WORM mask strips w/d/a even though the role
|
# subtree-admin of archive/<party>/, so the WORM constraint still
|
||||||
# grant supplies rwcda at the party level above).
|
# binds them in received/issued. Plan-Review approval is part of
|
||||||
# - rwcd explicit at incoming/ and staging/ (the QC and
|
# this role by design — there is no separate `approver` role;
|
||||||
# transmittal-out workflows need `d` to move files between
|
# two-person sign-off, when needed, is expressed via per-folder
|
||||||
# slots; the explicit grants shadow the inherited rwcda
|
# `.zddc` overrides rather than baked-in roles.
|
||||||
# to make the intent visible).
|
|
||||||
# - rwc at archive/ so they can create party subfolders.
|
|
||||||
#
|
|
||||||
# NOT a subtree-admin anywhere. There is no `admins:` entry for
|
|
||||||
# the role — DCs cannot bypass WORM (only worm-create via the
|
|
||||||
# list) and cannot reach inside fenced working homes. Admin
|
|
||||||
# elevation is reserved for the root admins: list (the human
|
|
||||||
# escape hatch for mis-filed documents or recovery).
|
|
||||||
#
|
|
||||||
# Plan-Review approval is part of this role by design — there is
|
|
||||||
# no separate `approver` role; two-person sign-off, when needed,
|
|
||||||
# is expressed via per-folder `.zddc` overrides rather than
|
|
||||||
# baked-in roles.
|
|
||||||
#
|
#
|
||||||
# project_team — everyone working on a project. Read-only across
|
# project_team — everyone working on a project. Read-only across
|
||||||
# the project by default, with a one-way ratchet through the
|
# the project. Their own archive/<party>/working/<email>/ home and
|
||||||
# in-flight slots:
|
# anything they create under incoming/ get a creator-owned auto-
|
||||||
#
|
# own .zddc (rwcda) which wins via deepest-match, so "read-only
|
||||||
# working/ cr — create + read; the auto_own_fenced child
|
# except what I own" falls out of the cascade with no special rule.
|
||||||
# gives the creator rwcda in their own home,
|
|
||||||
# fenced from siblings
|
|
||||||
# staging/ cr — drop + read, no modify (after drop, the
|
|
||||||
# doc-controller is the only one who can
|
|
||||||
# change it)
|
|
||||||
# reviewing/ cr — create + read; auto_own (unfenced) gives
|
|
||||||
# creator rwcda in their iteration folder,
|
|
||||||
# siblings see it via project-level :r
|
|
||||||
# received/ r — WORM zone; only document_controller can
|
|
||||||
# file (and even they need elevation to edit)
|
|
||||||
# issued/ r — WORM zone; published, immutable
|
|
||||||
# incoming/ r — counterparty's drop zone (project_team
|
|
||||||
# observers it, doc_controller QCs it)
|
|
||||||
#
|
|
||||||
# "Each handoff drops the role's modify rights for the previous
|
|
||||||
# slot." That's the model — project_team works freely in
|
|
||||||
# working/, commits to staging/, and from there the doc-
|
|
||||||
# controller takes over.
|
|
||||||
#
|
#
|
||||||
# observer — pure read-only across the project. Like project_team
|
# observer — pure read-only across the project. Like project_team
|
||||||
# but with no auto-own home: an observer who somehow created a
|
# but with no auto-own home: an observer who somehow created a
|
||||||
|
|
@ -228,31 +197,19 @@ paths:
|
||||||
paths:
|
paths:
|
||||||
# Second segment under archive/ is the party name.
|
# Second segment under archive/ is the party name.
|
||||||
"*":
|
"*":
|
||||||
# When the doc controller creates a party folder, the
|
# When the doc controller creates a party folder, an
|
||||||
# auto-own .zddc grants:
|
# auto-own .zddc grants them rwcda there (UNFENCED — so
|
||||||
# - the creator's email rwcda (the standard auto_own
|
# the project-level project_team:r still cascades through
|
||||||
# mechanism)
|
# to received/issued). That lets them set up the
|
||||||
# - the document_controller role rwcda (auto_own_roles
|
# counterparty's own .zddc afterward.
|
||||||
# below) so any DC in the role has full authority at
|
|
||||||
# every party, not just the parties they personally
|
|
||||||
# mkdir'd
|
|
||||||
#
|
|
||||||
# UNFENCED — so the project-level project_team:r still
|
|
||||||
# cascades through to received/issued/incoming. That
|
|
||||||
# lets the DC who created the party set up the counter-
|
|
||||||
# party's own .zddc afterward (e.g. granting them cr at
|
|
||||||
# incoming/).
|
|
||||||
#
|
|
||||||
# No `admins:` here by design. The DC role gets full
|
|
||||||
# authority via the role grant in the auto-own .zddc, not
|
|
||||||
# via subtree-admin status — so WORM masks at
|
|
||||||
# received/issued still bind them (they file write-once
|
|
||||||
# via the worm: list), and per-user fenced homes under
|
|
||||||
# working/ stay private to their creators. Admin
|
|
||||||
# elevation is reserved for the root admins list (the
|
|
||||||
# actual sudo-style escape hatch).
|
|
||||||
auto_own: true
|
auto_own: true
|
||||||
auto_own_roles: [document_controller]
|
# 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]
|
||||||
# SSR record: the party folder's ssr.yaml carries this
|
# SSR record: the party folder's ssr.yaml carries this
|
||||||
# party's vendor / contract / status data. Scoped by
|
# party's vendor / contract / status data. Scoped by
|
||||||
# filename pattern so the lock on `kind` only applies to
|
# filename pattern so the lock on `kind` only applies to
|
||||||
|
|
@ -367,49 +324,9 @@ paths:
|
||||||
# level <project>/{working,staging,reviewing} virtuals
|
# level <project>/{working,staging,reviewing} virtuals
|
||||||
# (declared above) are folder-nav views over these
|
# (declared above) are folder-nav views over these
|
||||||
# canonical per-party slots.
|
# canonical per-party slots.
|
||||||
# ── In-flight ratchet ───────────────────────────────
|
|
||||||
#
|
|
||||||
# The lifecycle slots form a one-way handoff:
|
|
||||||
#
|
|
||||||
# working/ → staging/ → issued/ (WORM)
|
|
||||||
# (full) (cr) (worm cr)
|
|
||||||
#
|
|
||||||
# At each step the previous role's modify rights drop:
|
|
||||||
# project_team iterates freely in working/; when they
|
|
||||||
# promote to staging/ they can't change it without doc-
|
|
||||||
# controller help; when DC publishes to issued/ even
|
|
||||||
# they can't change it without elevation. Each ACL
|
|
||||||
# grant below is the verb-set the ROLE keeps at that
|
|
||||||
# step; auto_own + auto_own_fenced sub-folder grants
|
|
||||||
# layer per-creator ownership on top of these.
|
|
||||||
working:
|
working:
|
||||||
default_tool: browse
|
default_tool: browse
|
||||||
available_tools: [browse, classifier]
|
available_tools: [browse, classifier]
|
||||||
# Project_team gets read + create here so they can
|
|
||||||
# mkdir their own home folder (and any shared sub-
|
|
||||||
# folders). The auto_own_fenced declaration at the
|
|
||||||
# `*` child below makes the new folder a private home
|
|
||||||
# with rwcda for the creator (fenced from ancestors,
|
|
||||||
# so collaborators only join after the owner edits
|
|
||||||
# the home's .zddc to grant them access).
|
|
||||||
#
|
|
||||||
# `cr` instead of just `c` so an existing file at
|
|
||||||
# working/ root stays readable to all team members
|
|
||||||
# (cascade is per-level deepest-match — a single `c`
|
|
||||||
# would shadow the project-level `r`).
|
|
||||||
#
|
|
||||||
# `document_controller: rwcda` is restated here so a
|
|
||||||
# DC whose email is ALSO matched by project_team
|
|
||||||
# (typical when project_team is `*@example.com`) gets
|
|
||||||
# the higher grant via within-level union. Without
|
|
||||||
# the restatement, the cascade's deepest-level-wins
|
|
||||||
# would pick project_team's cr and shadow the DC's
|
|
||||||
# rwcda inherited from the party's auto-own .zddc.
|
|
||||||
# Same pattern applied at staging/ and reviewing/.
|
|
||||||
acl:
|
|
||||||
permissions:
|
|
||||||
project_team: cr
|
|
||||||
document_controller: rwcda
|
|
||||||
# working/ auto-owns the first creator + the per-user
|
# working/ auto-owns the first creator + the per-user
|
||||||
# homes below.
|
# homes below.
|
||||||
auto_own: true
|
auto_own: true
|
||||||
|
|
@ -428,32 +345,6 @@ paths:
|
||||||
staging:
|
staging:
|
||||||
default_tool: transmittal
|
default_tool: transmittal
|
||||||
available_tools: [transmittal, classifier]
|
available_tools: [transmittal, classifier]
|
||||||
# The ratchet step from working/. project_team gets
|
|
||||||
# `cr` — they can drop files (PUT new files at
|
|
||||||
# staging/) and read what's there, but cannot edit or
|
|
||||||
# delete after the drop. Once a file is in staging it
|
|
||||||
# belongs to the doc-controller workflow; the team
|
|
||||||
# member needs to ask DC to change it.
|
|
||||||
#
|
|
||||||
# Convention: project_team drops FILES at staging/,
|
|
||||||
# not sub-folders. A sub-folder mkdir'd by project_
|
|
||||||
# team would trigger auto_own and grant them rwcda
|
|
||||||
# inside their own sub-folder (auto_own is path-keyed,
|
|
||||||
# not role-keyed — it fires for any creator). The
|
|
||||||
# auto_own here is preserved for DC's per-transmittal
|
|
||||||
# mkdir flow; project_team can keep to file drops to
|
|
||||||
# honour the "can't alter after" intent.
|
|
||||||
#
|
|
||||||
# DC gets rwcda explicitly — `d` for the cut to issued/,
|
|
||||||
# `a` so Plan Review can write the staging/<tracking>/.zddc
|
|
||||||
# the composite endpoint scaffolds. Restated here (not
|
|
||||||
# inherited from the party-level role grant) so the
|
|
||||||
# within-level union dominates project_team's cr for
|
|
||||||
# any DC matched by the team wildcard.
|
|
||||||
acl:
|
|
||||||
permissions:
|
|
||||||
project_team: cr
|
|
||||||
document_controller: rwcda
|
|
||||||
auto_own: true
|
auto_own: true
|
||||||
drop_target: true
|
drop_target: true
|
||||||
reviewing:
|
reviewing:
|
||||||
|
|
@ -468,21 +359,5 @@ paths:
|
||||||
# from the party-level admins:) so the doc
|
# from the party-level admins:) so the doc
|
||||||
# controller can author per-folder .zddc files
|
# controller can author per-folder .zddc files
|
||||||
# (originator ACL, planned_date).
|
# (originator ACL, planned_date).
|
||||||
#
|
|
||||||
# project_team gets `cr` so the originating team can
|
|
||||||
# create review-iteration folders alongside the
|
|
||||||
# Plan-Review-scaffolded ones. auto_own (unfenced
|
|
||||||
# here, unlike working/) gives the creator rwcda
|
|
||||||
# inside; siblings see the iteration via the project-
|
|
||||||
# level project_team:r cascade.
|
|
||||||
#
|
|
||||||
# document_controller: rwcda restated for the same
|
|
||||||
# reason as working/ + staging/ — keeps a DC matched
|
|
||||||
# by the project_team wildcard at full authority via
|
|
||||||
# within-level union.
|
|
||||||
acl:
|
|
||||||
permissions:
|
|
||||||
project_team: cr
|
|
||||||
document_controller: rwcda
|
|
||||||
auto_own: true
|
auto_own: true
|
||||||
drop_target: true
|
drop_target: true
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,6 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
absPath string
|
absPath string
|
||||||
autoOwn bool
|
autoOwn bool
|
||||||
fenced bool
|
fenced bool
|
||||||
roles []string
|
|
||||||
}
|
}
|
||||||
var freshlyCreated []created
|
var freshlyCreated []created
|
||||||
|
|
||||||
|
|
@ -225,15 +224,10 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
_ = i
|
_ = i
|
||||||
autoOwn := AutoOwnAt(fsRoot, pathSoFar)
|
autoOwn := AutoOwnAt(fsRoot, pathSoFar)
|
||||||
fenced := autoOwn && AutoOwnFencedAt(fsRoot, pathSoFar)
|
fenced := autoOwn && AutoOwnFencedAt(fsRoot, pathSoFar)
|
||||||
var roles []string
|
|
||||||
if autoOwn {
|
|
||||||
roles = AutoOwnRolesAt(fsRoot, pathSoFar)
|
|
||||||
}
|
|
||||||
freshlyCreated = append(freshlyCreated, created{
|
freshlyCreated = append(freshlyCreated, created{
|
||||||
absPath: pathSoFar,
|
absPath: pathSoFar,
|
||||||
autoOwn: autoOwn,
|
autoOwn: autoOwn,
|
||||||
fenced: fenced,
|
fenced: fenced,
|
||||||
roles: roles,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,10 +235,7 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
// created. Skip if no principal email is available (anonymous or
|
// created. Skip if no principal email is available (anonymous or
|
||||||
// system writes). The fenced variant is used at per-user home
|
// system writes). The fenced variant is used at per-user home
|
||||||
// folders under working/ — private by default; owner can later
|
// folders under working/ — private by default; owner can later
|
||||||
// edit the .zddc to add collaborators. Role grants (from the
|
// edit the .zddc to add collaborators.
|
||||||
// cascade's auto_own_roles list) are written alongside the
|
|
||||||
// creator email so role-level peer authority survives without
|
|
||||||
// needing a subtree-admin grant.
|
|
||||||
if principalEmail != "" {
|
if principalEmail != "" {
|
||||||
for _, c := range freshlyCreated {
|
for _, c := range freshlyCreated {
|
||||||
if !c.autoOwn {
|
if !c.autoOwn {
|
||||||
|
|
@ -252,9 +243,9 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
}
|
}
|
||||||
var werr error
|
var werr error
|
||||||
if c.fenced {
|
if c.fenced {
|
||||||
werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail, c.roles)
|
werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail)
|
||||||
} else {
|
} else {
|
||||||
werr = WriteAutoOwnZddc(c.absPath, principalEmail, c.roles)
|
werr = WriteAutoOwnZddc(c.absPath, principalEmail)
|
||||||
}
|
}
|
||||||
if werr != nil {
|
if werr != nil {
|
||||||
return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, werr)
|
return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, werr)
|
||||||
|
|
|
||||||
|
|
@ -125,9 +125,9 @@ type ConvertMetadata struct {
|
||||||
// place = URL-fetched apps refused (only embedded + local-path apps
|
// place = URL-fetched apps refused (only embedded + local-path apps
|
||||||
// work). See zddc-server's setupApps.
|
// work). See zddc-server's setupApps.
|
||||||
type ZddcFile struct {
|
type ZddcFile struct {
|
||||||
ACL ACLRules `yaml:"acl,omitempty" json:"acl,omitempty"`
|
ACL ACLRules `yaml:"acl" json:"acl"`
|
||||||
Admins []string `yaml:"admins,omitempty" json:"admins,omitempty"`
|
Admins []string `yaml:"admins" json:"admins,omitempty"`
|
||||||
Title string `yaml:"title,omitempty" json:"title,omitempty"`
|
Title string `yaml:"title" json:"title,omitempty"`
|
||||||
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
|
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
|
||||||
AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`
|
AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`
|
||||||
|
|
||||||
|
|
@ -231,23 +231,6 @@ type ZddcFile struct {
|
||||||
// admin grants still apply.
|
// admin grants still apply.
|
||||||
AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"`
|
AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"`
|
||||||
|
|
||||||
// AutoOwnRoles augments AutoOwn with role-level grants: when set,
|
|
||||||
// the auto-own .zddc written at a new child directory grants each
|
|
||||||
// listed role `rwcda` ALONGSIDE the creator email. Lets the schema
|
|
||||||
// express "the creator owns it AND any member of these roles has
|
|
||||||
// full authority" without resorting to a separate admins: list
|
|
||||||
// (which would be subtree-admin and bypass WORM / fences via
|
|
||||||
// elevation — too strong for typical workflows).
|
|
||||||
//
|
|
||||||
// Example: archive/<party>/ sets `auto_own_roles: [document_controller]`
|
|
||||||
// so any DC has rwcda at every party folder a peer created, not
|
|
||||||
// just at parties they personally mkdir'd.
|
|
||||||
//
|
|
||||||
// Grants are written as plain permissions in the new .zddc — they
|
|
||||||
// have no special semantic beyond what `rwcda` already means in
|
|
||||||
// the cascade. A fence (auto_own_fenced) still binds them.
|
|
||||||
AutoOwnRoles []string `yaml:"auto_own_roles,omitempty" json:"auto_own_roles,omitempty"`
|
|
||||||
|
|
||||||
// Virtual marks a directory as never-materialise-on-disk. The
|
// Virtual marks a directory as never-materialise-on-disk. The
|
||||||
// server treats requests under such a path as virtual routes
|
// server treats requests under such a path as virtual routes
|
||||||
// rather than triggering EnsureCanonicalAncestors. The reviewing
|
// rather than triggering EnsureCanonicalAncestors. The reviewing
|
||||||
|
|
|
||||||
|
|
@ -75,23 +75,6 @@ func AutoOwnAt(fsRoot, dirPath string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutoOwnRolesAt returns the role names that should be granted rwcda
|
|
||||||
// in the auto-own .zddc at this dir (alongside the creator's email).
|
|
||||||
// Leaf-only, same semantic as AutoOwnAt / AutoOwnFencedAt. Empty/nil
|
|
||||||
// when the cascade declares no role grants — the legacy creator-only
|
|
||||||
// behavior. Caller passes the result to WriteAutoOwnZddc / Fenced.
|
|
||||||
func AutoOwnRolesAt(fsRoot, dirPath string) []string {
|
|
||||||
chain, err := EffectivePolicy(fsRoot, dirPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
leaf := leafLevel(chain)
|
|
||||||
if leaf.AutoOwnRoles != nil {
|
|
||||||
return leaf.AutoOwnRoles
|
|
||||||
}
|
|
||||||
return chain.Embedded.AutoOwnRoles
|
|
||||||
}
|
|
||||||
|
|
||||||
// AutoOwnFencedAt reports whether the auto-own .zddc at this dir
|
// AutoOwnFencedAt reports whether the auto-own .zddc at this dir
|
||||||
// should be written with `inherit: false` (private to creator).
|
// should be written with `inherit: false` (private to creator).
|
||||||
// Leaf-only, same semantic as AutoOwnAt.
|
// Leaf-only, same semantic as AutoOwnAt.
|
||||||
|
|
|
||||||
|
|
@ -11,24 +11,19 @@ import (
|
||||||
// IsDeclaredPath, ChildrenDeclaredAt, AvailableToolsAt).
|
// IsDeclaredPath, ChildrenDeclaredAt, AvailableToolsAt).
|
||||||
|
|
||||||
// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
|
// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
|
||||||
// principalEmail rwcda and recording it in CreatedBy. Each role name
|
// principalEmail rwcda and recording it in CreatedBy. Used by the file
|
||||||
// in roles also receives rwcda — gives the schema a way to declare
|
// API's mkdir post-hook (and by EnsureCanonicalAncestors) to seed
|
||||||
// "this folder is creator-owned AND any member of these roles has full
|
// ownership when a new auto-own folder is materialised.
|
||||||
// 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 grants are identical to what an operator would write by hand —
|
// The grant is identical to what an operator would write by hand —
|
||||||
// direct email pattern + bare role names, "rwcda" verb set — so the
|
// direct email pattern, "rwcda" verb set — so the creator can later
|
||||||
// creator can later edit the file normally to narrow or extend them.
|
// edit the file normally to add collaborators.
|
||||||
//
|
//
|
||||||
// Atomic: marshals via the same yaml encoder ParseFile reads
|
// Atomic: marshals via the same yaml encoder ParseFile reads
|
||||||
// (round-trip guaranteed) and writes via zddc.WriteFile (which
|
// (round-trip guaranteed) and writes via zddc.WriteFile (which
|
||||||
// performs an atomic temp-write + rename via zddc.WriteAtomic).
|
// performs an atomic temp-write + rename via zddc.WriteAtomic).
|
||||||
func WriteAutoOwnZddc(dir, principalEmail string, roles []string) error {
|
func WriteAutoOwnZddc(dir, principalEmail string) error {
|
||||||
return writeAutoOwn(dir, principalEmail, false, roles)
|
return writeAutoOwn(dir, principalEmail, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally
|
// WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally
|
||||||
|
|
@ -39,26 +34,14 @@ func WriteAutoOwnZddc(dir, principalEmail string, roles []string) error {
|
||||||
// Without the fence, an ancestor `*: r` (e.g. a project-root grant for
|
// Without the fence, an ancestor `*: r` (e.g. a project-root grant for
|
||||||
// authenticated users) would let any user read every other user's
|
// authenticated users) would let any user read every other user's
|
||||||
// working subfolder via cascade — defeating the per-user sandbox.
|
// working subfolder via cascade — defeating the per-user sandbox.
|
||||||
//
|
func WriteAutoOwnZddcFenced(dir, principalEmail string) error {
|
||||||
// roles is the same as for WriteAutoOwnZddc — listed roles get rwcda
|
return writeAutoOwn(dir, principalEmail, true)
|
||||||
// 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, roles []string) error {
|
func writeAutoOwn(dir, principalEmail string, fenced bool) error {
|
||||||
perms := map[string]string{principalEmail: "rwcda"}
|
rules := ACLRules{
|
||||||
for _, role := range roles {
|
Permissions: map[string]string{principalEmail: "rwcda"},
|
||||||
if role == "" || role == principalEmail {
|
|
||||||
continue // skip empty / collision with the creator entry
|
|
||||||
}
|
}
|
||||||
perms[role] = "rwcda"
|
|
||||||
}
|
|
||||||
rules := ACLRules{Permissions: perms}
|
|
||||||
if fenced {
|
if fenced {
|
||||||
f := false
|
f := false
|
||||||
rules.Inherit = &f
|
rules.Inherit = &f
|
||||||
|
|
|
||||||
|
|
@ -6,53 +6,29 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestStandardRoles_DocControllerScopedCreate — DC authority comes
|
// TestStandardRoles_DocControllerScopedCreate — with document_controller
|
||||||
// PURELY from the cascade now (no subtree-admin / admins: list). The
|
// populated at the on-disk root, the role gets:
|
||||||
// model:
|
// - rw at the project level (read + overwrite-existing), but NOT c
|
||||||
// - rw at the project level (read + overwrite-existing, no `c`)
|
// (so it can't make arbitrary folders)
|
||||||
// - rwc at archive/ (can create party subfolders)
|
// - rwc at archive/ (can create party subfolders)
|
||||||
// - When DC mkdirs archive/<party>/, ensure.go writes an auto-own
|
// - subtree-admin at archive/<party>/ (full create + manage; lifecycle
|
||||||
// .zddc granting both the creator email AND the document_controller
|
// slots under the party inherit the admin grant)
|
||||||
// role rwcda there (via auto_own_roles in defaults). This test
|
// - inside received/issued (WORM): masked to r + worm-restored c
|
||||||
// simulates that .zddc directly so the cascade behaviour can be
|
//
|
||||||
// asserted in isolation.
|
// Layout reshape: working/staging/reviewing moved from project root
|
||||||
// - From the party's auto-own .zddc, the role rwcda cascades down to
|
// into archive/<party>/, so the subtree-admin scope likewise moved
|
||||||
// descendants by default; explicit slot grants (rwcd at incoming/
|
// from project-level "working/staging/" to the per-party folder.
|
||||||
// and staging/) shadow it where the workflow needs `d`.
|
|
||||||
// - At received/issued (WORM): the WORM mask strips w/d/a from the
|
|
||||||
// inherited rwcda; the worm: list restores c → effective cr.
|
|
||||||
// - NOT subtree-admin anywhere — no admins: entries for the role.
|
|
||||||
func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
||||||
resetCache()
|
resetCache()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
// DCs are typically internal employees and ARE in project_team
|
// Deployment populates the standard roles. Roles UNION with the
|
||||||
// (which is commonly defined as the *@example.com wildcard). The
|
// embedded (empty) definitions, so this is the effective member set.
|
||||||
// embedded defaults restate document_controller:rwcda at every
|
|
||||||
// slot that grants project_team a narrower verb set; the
|
|
||||||
// cascade's within-level union then gives the DC the higher
|
|
||||||
// grant. This fixture mirrors the realistic deployment shape so
|
|
||||||
// the union behavior is actually exercised.
|
|
||||||
writeZddc(t, root, `roles:
|
writeZddc(t, root, `roles:
|
||||||
document_controller:
|
document_controller:
|
||||||
members: ["dc@example.com"]
|
members: ["dc@example.com"]
|
||||||
project_team:
|
project_team:
|
||||||
members: ["*@example.com"]
|
members: ["*@example.com"]
|
||||||
`)
|
`)
|
||||||
// Simulate the auto-own .zddc the file API writes when DC mkdir's
|
|
||||||
// archive/Acme/. Carries the creator email + the document_controller
|
|
||||||
// role per the embedded defaults' auto_own_roles entry.
|
|
||||||
partyDir := filepath.Join(root, "Proj", "archive", "Acme")
|
|
||||||
if err := os.MkdirAll(partyDir, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
writeZddc(t, partyDir, `acl:
|
|
||||||
permissions:
|
|
||||||
"dc@example.com": rwcda
|
|
||||||
document_controller: rwcda
|
|
||||||
created_by: dc@example.com
|
|
||||||
`)
|
|
||||||
resetCache()
|
|
||||||
|
|
||||||
dc := "dc@example.com"
|
dc := "dc@example.com"
|
||||||
|
|
||||||
mustVerbs := func(dir string, want string) {
|
mustVerbs := func(dir string, want string) {
|
||||||
|
|
@ -61,6 +37,7 @@ created_by: dc@example.com
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("EffectivePolicy(%q): %v", dir, err)
|
t.Fatalf("EffectivePolicy(%q): %v", dir, err)
|
||||||
}
|
}
|
||||||
|
// Mirror InternalDecider.Allow's WORM-aware composition.
|
||||||
var got VerbSet
|
var got VerbSet
|
||||||
if g, inWorm := WormZoneGrant(chain, dc); inWorm {
|
if g, inWorm := WormZoneGrant(chain, dc); inWorm {
|
||||||
got = (EffectiveVerbs(chain, dc) & VerbR) | (g & VerbsRC)
|
got = (EffectiveVerbs(chain, dc) & VerbR) | (g & VerbsRC)
|
||||||
|
|
@ -78,94 +55,36 @@ created_by: dc@example.com
|
||||||
mustVerbs(filepath.Join(root, "Proj", "random-folder"), "rw")
|
mustVerbs(filepath.Join(root, "Proj", "random-folder"), "rw")
|
||||||
// archive/: rwc (can create party folders).
|
// archive/: rwc (can create party folders).
|
||||||
mustVerbs(filepath.Join(root, "Proj", "archive"), "rwc")
|
mustVerbs(filepath.Join(root, "Proj", "archive"), "rwc")
|
||||||
// At the party folder itself: rwcda via the auto-own role grant.
|
// incoming/: rwcd — the QC + transfer-out workflow needs delete.
|
||||||
mustVerbs(partyDir, "rwcda")
|
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "incoming"), "rwcd")
|
||||||
// Lifecycle slots inside the party folder inherit rwcda from the
|
// received/ (WORM): rw masked to r, plus worm-restored c → "rc".
|
||||||
// party-level role grant where no slot-local grant overrides.
|
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "rc")
|
||||||
mustVerbs(filepath.Join(partyDir, "working"), "rwcda")
|
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "rc")
|
||||||
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")
|
|
||||||
|
|
||||||
// NOT subtree-admin anywhere — even when notionally elevated,
|
// Subtree-admin at archive/<party>/ (the embedded cascade
|
||||||
// the role carries no admin: grant.
|
// declares admins: [document_controller] on the party "*" entry,
|
||||||
for _, p := range []string{
|
// so working/staging/reviewing inside the party inherit it).
|
||||||
filepath.Join(root, "Proj"),
|
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme"), Principal{Email: dc, Elevated: true}) {
|
||||||
filepath.Join(root, "Proj", "archive"),
|
t.Errorf("doc controller should be subtree-admin of archive/<party>/")
|
||||||
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
|
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "staging"), Principal{Email: dc, Elevated: true}) {
|
||||||
// working home. The fence isolates team-member workspaces from
|
t.Errorf("doc controller should be subtree-admin of archive/<party>/staging/")
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
writeZddc(t, homeDir, `acl:
|
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "reviewing"), Principal{Email: dc, Elevated: true}) {
|
||||||
inherit: false
|
t.Errorf("doc controller should be subtree-admin of archive/<party>/reviewing/")
|
||||||
permissions:
|
|
||||||
"alice@example.com": rwcda
|
|
||||||
created_by: alice@example.com
|
|
||||||
`)
|
|
||||||
resetCache()
|
|
||||||
chain, _ := EffectivePolicy(root, homeDir)
|
|
||||||
if got := EffectiveVerbs(chain, dc); got != 0 {
|
|
||||||
t.Errorf("doc controller inside alice's fenced home = %q, want empty (fence isolates)", got.String())
|
|
||||||
}
|
}
|
||||||
}
|
// NOT subtree-admin of archive/ (so WORM still binds them at the
|
||||||
|
// received/issued slots below).
|
||||||
// TestStandardRoles_DocControllerMultiDC — a second DC added to the
|
if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), Principal{Email: dc, Elevated: true}) {
|
||||||
// role gets the SAME rwcda at every party that any DC created,
|
t.Errorf("doc controller should NOT be subtree-admin of archive/ (only of each party folder)")
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
writeZddc(t, partyDir, `acl:
|
// Subtree-admin reaches inside a fenced per-user working home
|
||||||
permissions:
|
// under the party's working slot.
|
||||||
"dc1@example.com": rwcda
|
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), Principal{Email: dc, Elevated: true}) {
|
||||||
document_controller: rwcda
|
t.Errorf("doc controller (subtree-admin of party) should reach inside a fenced user home")
|
||||||
created_by: dc1@example.com
|
|
||||||
`)
|
|
||||||
resetCache()
|
|
||||||
|
|
||||||
chain, _ := EffectivePolicy(root, partyDir)
|
|
||||||
// dc1 (creator) has rwcda directly.
|
|
||||||
if got := EffectiveVerbs(chain, "dc1@example.com"); got.String() != "rwcda" {
|
|
||||||
t.Errorf("dc1 (creator) at party = %q, want rwcda", got.String())
|
|
||||||
}
|
|
||||||
// dc2 (non-creator) ALSO has rwcda via the role grant. This is
|
|
||||||
// the whole point of auto_own_roles — peer DCs share authority
|
|
||||||
// without admin status.
|
|
||||||
if got := EffectiveVerbs(chain, "dc2@example.com"); got.String() != "rwcda" {
|
|
||||||
t.Errorf("dc2 (peer) at party = %q, want rwcda (role grant from auto_own_roles)", got.String())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,73 +141,6 @@ created_by: alice@example.com
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStandardRoles_ProjectTeamInFlightRatchet — the one-way handoff
|
|
||||||
// from working/ → staging/ → issued/ as the team member sees it:
|
|
||||||
// full work in working (cr at the slot + rwcda inside the fenced
|
|
||||||
// home), drop-only in staging (cr — no modify after the drop), drop
|
|
||||||
// inside auto-own iteration folder in reviewing (cr at the slot,
|
|
||||||
// rwcda inside the auto-owned sub-folder), read-only in received/
|
|
||||||
// issued (WORM zones) and incoming/ (counterparty drop zone).
|
|
||||||
func TestStandardRoles_ProjectTeamInFlightRatchet(t *testing.T) {
|
|
||||||
resetCache()
|
|
||||||
root := t.TempDir()
|
|
||||||
writeZddc(t, root, `roles:
|
|
||||||
project_team:
|
|
||||||
members: ["*@example.com"]
|
|
||||||
`)
|
|
||||||
alice := "alice@example.com"
|
|
||||||
|
|
||||||
mustVerbs := func(dir string, want string) {
|
|
||||||
t.Helper()
|
|
||||||
chain, err := EffectivePolicy(root, dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("EffectivePolicy(%q): %v", dir, err)
|
|
||||||
}
|
|
||||||
var got VerbSet
|
|
||||||
if g, inWorm := WormZoneGrant(chain, alice); inWorm {
|
|
||||||
got = (EffectiveVerbs(chain, alice) & VerbR) | (g & VerbsRC)
|
|
||||||
} else {
|
|
||||||
got = EffectiveVerbs(chain, alice)
|
|
||||||
}
|
|
||||||
if got.String() != want {
|
|
||||||
t.Errorf("project_team alice at %s = %q, want %q", dir[len(root):], got.String(), want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
party := filepath.Join(root, "Proj", "archive", "Acme")
|
|
||||||
mustVerbs(filepath.Join(party, "working"), "rc") // create + read at slot
|
|
||||||
mustVerbs(filepath.Join(party, "staging"), "rc") // drop + read, no modify
|
|
||||||
mustVerbs(filepath.Join(party, "reviewing"), "rc") // create iteration folders
|
|
||||||
mustVerbs(filepath.Join(party, "received"), "r") // WORM — read pass-through, no worm-create
|
|
||||||
mustVerbs(filepath.Join(party, "issued"), "r") // WORM — same
|
|
||||||
mustVerbs(filepath.Join(party, "incoming"), "r") // counterparty drop zone — read only
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStandardRoles_DocControllerStagingDelete — DC needs `d` at
|
|
||||||
// staging/ to perform the staging-to-issued transfer (cut, not copy);
|
|
||||||
// the explicit document_controller: rwcd grant supplies it. Mirrors
|
|
||||||
// the incoming/ pattern (line 286-288 of defaults.zddc.yaml) where
|
|
||||||
// the QC + transfer-out workflow needed the same.
|
|
||||||
func TestStandardRoles_DocControllerStagingDelete(t *testing.T) {
|
|
||||||
resetCache()
|
|
||||||
root := t.TempDir()
|
|
||||||
writeZddc(t, root, `roles:
|
|
||||||
document_controller:
|
|
||||||
members: ["dc@example.com"]
|
|
||||||
`)
|
|
||||||
dc := "dc@example.com"
|
|
||||||
chain, err := EffectivePolicy(root, filepath.Join(root, "Proj", "archive", "Acme", "staging"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("EffectivePolicy: %v", err)
|
|
||||||
}
|
|
||||||
got := EffectiveVerbs(chain, dc)
|
|
||||||
for _, v := range []VerbSet{VerbR, VerbW, VerbC, VerbD} {
|
|
||||||
if !got.Has(v) {
|
|
||||||
t.Errorf("doc controller at staging/ missing verb %q in %q (need rwcd for transfer-to-issued)", v.String(), got.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStandardRoles_ObserverReadOnlyEverywhere — observer is the
|
// TestStandardRoles_ObserverReadOnlyEverywhere — observer is the
|
||||||
// project-wide read-only role for auditors / regulators / external
|
// project-wide read-only role for auditors / regulators / external
|
||||||
// viewers. Unlike project_team, an observer must not contribute
|
// viewers. Unlike project_team, an observer must not contribute
|
||||||
|
|
|
||||||
|
|
@ -78,13 +78,6 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
||||||
if top.AutoOwnFenced != nil {
|
if top.AutoOwnFenced != nil {
|
||||||
out.AutoOwnFenced = top.AutoOwnFenced
|
out.AutoOwnFenced = top.AutoOwnFenced
|
||||||
}
|
}
|
||||||
// AutoOwnRoles: presence (non-nil) overrides; a deeper level
|
|
||||||
// declaring an empty list replaces (and explicitly suppresses)
|
|
||||||
// the ancestor's role list. This matches the leaf-wins semantic
|
|
||||||
// for the other path-tree contribution lists.
|
|
||||||
if top.AutoOwnRoles != nil {
|
|
||||||
out.AutoOwnRoles = top.AutoOwnRoles
|
|
||||||
}
|
|
||||||
if top.DropTarget != nil {
|
if top.DropTarget != nil {
|
||||||
out.DropTarget = top.DropTarget
|
out.DropTarget = top.DropTarget
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue