Replaces the binary acl.allow/deny model with five permission verbs
(r/w/c/d/a) and first-class roles, and adds an authenticated file API
(PUT/DELETE/POST move/mkdir) so the HTML tools can edit-in-place over
HTTP. Closes the AC-3(7) and AC-6 federal-readiness gaps.
File API (zddc/internal/handler/fileapi.go)
- PUT <new> → action c
- PUT <existing> → action w
- PUT <.zddc> → action a (CanEditZddc strict-ancestor rule)
- DELETE → action d
- POST mkdir → action c (auto-writes creator-owned .zddc when the
parent is Incoming/Working/Staging)
- POST move → action w on src + c on dst, atomic via os.Rename
- Optional If-Match for optimistic concurrency, --max-write-bytes cap,
audit log emits a structured file_write event per operation.
Permission model (zddc/internal/zddc/{acl,file,roles,cascade_mode}.go)
- acl.permissions: { principal → verb-set } map; principals are email
patterns or role names. Empty verb set is an explicit deny.
- roles: { name → members } definitions, available at the level they
declare and all descendants. Closer-to-leaf shadows ancestor.
- Legacy acl.allow/deny still work; they fold into permissions at
parse time (allow → "rwcd", deny → "").
- Cascade walks leaf→root; first level with any matching entry wins;
the union of matching verb sets at that level decides.
- --cascade-mode=strict adds a root→leaf ancestor-deny pre-pass so an
ancestor explicit-deny is absolute (NIST AC-6). Default delegated
preserves the existing commercial behavior.
Special folders (zddc/internal/zddc/special.go)
- Incoming / Working / Staging: mkdir auto-writes a .zddc into the new
subdir granting created_by + that email rwcda directly. Same form
operators write by hand; creator can edit it later to add others.
- Issued / Received: server-enforced WORM split. Cascade grants
inherited from above the WORM folder are masked to r only; grants
placed at-or-below the WORM folder retain r,c. Operators grant
write-once (cr) to the doc controller via an explicit .zddc at the
Issued/Received folder. Admins exempt — only escape hatch.
Browser polyfill (shared/zddc-source.js)
- HttpDirectoryHandle + HttpFileHandle implement the FS Access API
surface (values, getFileHandle, createWritable, removeEntry,
queryPermission/requestPermission) over zddc-server's listing JSON
and file API. Existing tools written against showDirectoryPicker
work unchanged.
- detectServerRoot() returns { handle, status }: tools auto-load on
HTTP, surface a clear "no permission to list" message on 403, and
fall back to the welcome screen on 0.
- classifier renames take the atomic POST move path on HTTP-backed
handles; mdedit and transmittal route reads/writes through the
polyfill so prior FS-API code paths cover both modes.
Tests
- zddc/internal/zddc/{cascade_mode,roles,special,acl}_test.go cover
delegated vs strict, role membership / shadowing / legacy fallback,
WORM split semantics, verb-set parser round-trip.
- zddc/internal/handler/fileapi_test.go now also covers role-based
vendor scenarios, WORM blocking vendor & doc controller writes,
explicit Issued .zddc unlocking the cr drop-box, admin bypass,
auto-ownership on mkdir, and strict-mode lockouts.
Docs
- ARCHITECTURE.md + zddc/README.md document the verb model, role
syntax, special-folder behaviors, cascade-mode flag, and full file
API surface. Federal-readiness gap analysis strikes AC-3(7) and
AC-6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
368 lines
14 KiB
JavaScript
368 lines
14 KiB
JavaScript
// shared/zddc-source.js — source abstraction for tools that handle
|
|
// directory trees (classifier, mdedit, transmittal, browse, archive).
|
|
//
|
|
// Two backends:
|
|
//
|
|
// 1. Local — wraps a real FileSystemDirectoryHandle from the
|
|
// File System Access API. Reads + writes go through the
|
|
// FS Access API directly.
|
|
//
|
|
// 2. HTTP — talks to zddc-server's directory listing JSON
|
|
// (Accept: application/json) for reads and the file API
|
|
// (PUT/DELETE/POST X-ZDDC-Op) for writes. Implements a
|
|
// polyfill of the FS Access API surface area the tools
|
|
// use (kind, name, values(), getFileHandle, getDirectoryHandle,
|
|
// removeEntry, getFile, createWritable, queryPermission /
|
|
// requestPermission) so existing code works unchanged.
|
|
//
|
|
// The polyfill makes auto-load possible: when zddc-server serves
|
|
// a tool at /<dir>/<tool>.html, the tool detects HTTP mode at
|
|
// startup, builds an HttpDirectoryHandle for the tool's containing
|
|
// directory, and hands it to the existing openDirectory(handle)
|
|
// flow without ever showing the file picker.
|
|
//
|
|
// Renames inside a tool today are typically done as
|
|
// "write new + remove old". With HTTP-backed handles this becomes
|
|
// PUT + DELETE — non-atomic. Tools that prefer the atomic server
|
|
// MOVE should call window.zddc.source.moveFile(srcUrl, dstUrl)
|
|
// directly instead of going through the polyfill.
|
|
(function () {
|
|
'use strict';
|
|
|
|
if (!window.zddc) window.zddc = {};
|
|
var FA = window.FileSystemDirectoryHandle || null;
|
|
|
|
// -----------------------------------------------------------------
|
|
// HTTP file API helpers
|
|
// -----------------------------------------------------------------
|
|
|
|
function joinUrl(base, name, isDir) {
|
|
if (!base.endsWith('/')) base = base + '/';
|
|
return base + encodeURIComponent(name) + (isDir ? '/' : '');
|
|
}
|
|
|
|
// Server returns directory entries with a trailing "/" on names.
|
|
// Strip it for the FS Access API name surface.
|
|
function stripSlash(name) {
|
|
return name.endsWith('/') ? name.slice(0, -1) : name;
|
|
}
|
|
|
|
async function httpListing(url) {
|
|
var resp = await fetch(url, {
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!resp.ok) {
|
|
var err = new Error('listing ' + url + ': HTTP ' + resp.status);
|
|
err.status = resp.status;
|
|
throw err;
|
|
}
|
|
var data = await resp.json();
|
|
if (!Array.isArray(data)) {
|
|
throw new Error('listing ' + url + ': non-array body');
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function httpExists(url) {
|
|
try {
|
|
var r = await fetch(url, { method: 'HEAD', credentials: 'same-origin' });
|
|
return r.ok;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// HttpFileHandle — FileSystemFileHandle polyfill
|
|
// -----------------------------------------------------------------
|
|
|
|
function makeFile(blob, name, modTime) {
|
|
return new File([blob], name, {
|
|
type: blob.type,
|
|
lastModified: modTime ? modTime.getTime() : Date.now()
|
|
});
|
|
}
|
|
|
|
function HttpFileHandle(url, name, size, modTime) {
|
|
this.kind = 'file';
|
|
this.name = name;
|
|
this._url = url;
|
|
this._size = size || 0;
|
|
this._modTime = modTime || null;
|
|
this._etag = null;
|
|
}
|
|
HttpFileHandle.prototype.getFile = async function () {
|
|
var resp = await fetch(this._url, { credentials: 'same-origin' });
|
|
if (!resp.ok) {
|
|
throw new Error('GET ' + this._url + ': ' + resp.status);
|
|
}
|
|
var etag = resp.headers.get('ETag');
|
|
if (etag) this._etag = etag.replace(/"/g, '');
|
|
var lm = resp.headers.get('Last-Modified');
|
|
var modTime = lm ? new Date(lm) : this._modTime;
|
|
var blob = await resp.blob();
|
|
return makeFile(blob, this.name, modTime);
|
|
};
|
|
HttpFileHandle.prototype.createWritable = async function () {
|
|
var chunks = [];
|
|
var handle = this;
|
|
return {
|
|
async write(data) {
|
|
if (data == null) return;
|
|
if (typeof data === 'object' && data && 'type' in data && data.type === 'write') {
|
|
chunks.push(data.data);
|
|
return;
|
|
}
|
|
if (typeof data === 'object' && data && 'type' in data) {
|
|
// seek/truncate not supported by HTTP backend
|
|
throw new Error('HttpFileHandle write op not supported: ' + data.type);
|
|
}
|
|
chunks.push(data);
|
|
},
|
|
async close() {
|
|
var blob = new Blob(chunks);
|
|
var resp = await fetch(handle._url, {
|
|
method: 'PUT',
|
|
body: blob,
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!resp.ok) {
|
|
var body = '';
|
|
try { body = await resp.text(); } catch (_) { /* ignore */ }
|
|
throw new Error('PUT ' + handle._url + ': ' + resp.status + ' ' + body);
|
|
}
|
|
var et = resp.headers.get('ETag');
|
|
if (et) handle._etag = et.replace(/"/g, '');
|
|
handle._size = blob.size;
|
|
},
|
|
async abort() { chunks = []; }
|
|
};
|
|
};
|
|
HttpFileHandle.prototype.queryPermission = async function () { return 'granted'; };
|
|
HttpFileHandle.prototype.requestPermission = async function () { return 'granted'; };
|
|
HttpFileHandle.prototype.isHttp = true;
|
|
HttpFileHandle.prototype.url = function () { return this._url; };
|
|
|
|
// -----------------------------------------------------------------
|
|
// HttpDirectoryHandle — FileSystemDirectoryHandle polyfill
|
|
// -----------------------------------------------------------------
|
|
|
|
function HttpDirectoryHandle(url, name) {
|
|
this.kind = 'directory';
|
|
if (!url.endsWith('/')) url = url + '/';
|
|
this._url = url;
|
|
this.name = name || guessNameFromUrl(url);
|
|
}
|
|
function guessNameFromUrl(url) {
|
|
var u = url.replace(/\/+$/, '');
|
|
var slash = u.lastIndexOf('/');
|
|
return slash >= 0 ? decodeURIComponent(u.substring(slash + 1)) : u;
|
|
}
|
|
HttpDirectoryHandle.prototype.values = function () {
|
|
var url = this._url;
|
|
return (async function* () {
|
|
var entries;
|
|
try {
|
|
entries = await httpListing(url);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
for (var i = 0; i < entries.length; i++) {
|
|
var e = entries[i];
|
|
var rawName = stripSlash(e.name);
|
|
var childUrl = joinUrl(url, rawName, e.is_dir);
|
|
if (e.is_dir) {
|
|
yield new HttpDirectoryHandle(childUrl, rawName);
|
|
} else {
|
|
var modTime = e.mod_time ? new Date(e.mod_time) : null;
|
|
yield new HttpFileHandle(childUrl, rawName, e.size || 0, modTime);
|
|
}
|
|
}
|
|
})();
|
|
};
|
|
HttpDirectoryHandle.prototype.entries = function () {
|
|
var iter = this.values();
|
|
return (async function* () {
|
|
for (;;) {
|
|
var step = await iter.next();
|
|
if (step.done) return;
|
|
yield [step.value.name, step.value];
|
|
}
|
|
})();
|
|
};
|
|
HttpDirectoryHandle.prototype.keys = function () {
|
|
var iter = this.values();
|
|
return (async function* () {
|
|
for (;;) {
|
|
var step = await iter.next();
|
|
if (step.done) return;
|
|
yield step.value.name;
|
|
}
|
|
})();
|
|
};
|
|
HttpDirectoryHandle.prototype.getFileHandle = async function (name, opts) {
|
|
opts = opts || {};
|
|
var url = joinUrl(this._url, name, false);
|
|
var exists = await httpExists(url);
|
|
if (!exists && !opts.create) {
|
|
var err = new Error('NotFoundError: ' + name);
|
|
err.name = 'NotFoundError';
|
|
throw err;
|
|
}
|
|
return new HttpFileHandle(url, name, 0, null);
|
|
};
|
|
HttpDirectoryHandle.prototype.getDirectoryHandle = async function (name, opts) {
|
|
opts = opts || {};
|
|
var url = joinUrl(this._url, name, true);
|
|
if (opts.create) {
|
|
var resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'X-ZDDC-Op': 'mkdir' },
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!resp.ok && resp.status !== 200 && resp.status !== 201) {
|
|
throw new Error('mkdir ' + url + ': ' + resp.status);
|
|
}
|
|
}
|
|
return new HttpDirectoryHandle(url, name);
|
|
};
|
|
HttpDirectoryHandle.prototype.removeEntry = async function (name, opts) {
|
|
opts = opts || {};
|
|
// Probe listing to discover whether name is a file or directory.
|
|
var entries;
|
|
try {
|
|
entries = await httpListing(this._url);
|
|
} catch (e) {
|
|
throw new Error('removeEntry probe failed: ' + e.message);
|
|
}
|
|
var match = null;
|
|
for (var i = 0; i < entries.length; i++) {
|
|
if (stripSlash(entries[i].name) === name) {
|
|
match = entries[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!match) {
|
|
var err = new Error('NotFoundError: ' + name);
|
|
err.name = 'NotFoundError';
|
|
throw err;
|
|
}
|
|
if (match.is_dir && !opts.recursive) {
|
|
// Server doesn't expose a recursive-delete endpoint yet,
|
|
// and FS Access API requires recursive=true to remove a
|
|
// non-empty directory anyway. Reject explicitly so the
|
|
// caller doesn't silently leave a stale tree behind.
|
|
var derr = new Error('Removing directories over HTTP is not supported');
|
|
derr.name = 'InvalidStateError';
|
|
throw derr;
|
|
}
|
|
var url = joinUrl(this._url, name, match.is_dir);
|
|
var resp = await fetch(url, { method: 'DELETE', credentials: 'same-origin' });
|
|
if (!resp.ok && resp.status !== 204) {
|
|
throw new Error('DELETE ' + url + ': ' + resp.status);
|
|
}
|
|
};
|
|
HttpDirectoryHandle.prototype.queryPermission = async function () { return 'granted'; };
|
|
HttpDirectoryHandle.prototype.requestPermission = async function () { return 'granted'; };
|
|
HttpDirectoryHandle.prototype.isHttp = true;
|
|
HttpDirectoryHandle.prototype.url = function () { return this._url; };
|
|
|
|
// -----------------------------------------------------------------
|
|
// Top-level helpers
|
|
// -----------------------------------------------------------------
|
|
|
|
// Strip a trailing tool .html (e.g. classifier.html) from a path
|
|
// to land on the "directory the tool was opened in".
|
|
function pathToDir(pathname) {
|
|
if (!pathname) return '/';
|
|
if (pathname.endsWith('/')) return pathname;
|
|
var slash = pathname.lastIndexOf('/');
|
|
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
|
|
}
|
|
|
|
// Probe the server-mode root for the current page. Returns:
|
|
//
|
|
// { handle: HttpDirectoryHandle, status: 200 } — server reachable, listing returned
|
|
// { handle: null, status: 403 } — server reachable but listing forbidden
|
|
// { handle: null, status: 0 } — not http(s), or server unreachable / non-JSON
|
|
//
|
|
// Tools that auto-load on startup distinguish 403 (show "no
|
|
// permission to list this directory" message) from 0 (fall back
|
|
// to local-mode welcome screen).
|
|
//
|
|
// Tool init pattern:
|
|
// if (location.protocol !== 'file:') {
|
|
// const r = await zddc.source.detectServerRoot();
|
|
// if (r.handle) await openDirectory(r.handle);
|
|
// else if (r.status === 403) showNoPermissionMessage();
|
|
// else showWelcome();
|
|
// } else { showWelcome(); }
|
|
async function detectServerRoot() {
|
|
if (typeof location === 'undefined') {
|
|
return { handle: null, status: 0 };
|
|
}
|
|
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
|
return { handle: null, status: 0 };
|
|
}
|
|
var dirPath = pathToDir(location.pathname);
|
|
var url = location.origin + dirPath;
|
|
try {
|
|
await httpListing(url);
|
|
} catch (e) {
|
|
if (e && e.status === 403) {
|
|
return { handle: null, status: 403 };
|
|
}
|
|
return { handle: null, status: 0 };
|
|
}
|
|
return {
|
|
handle: new HttpDirectoryHandle(url, guessNameFromUrl(url)),
|
|
status: 200,
|
|
};
|
|
}
|
|
|
|
// Atomic file move. Path arguments are absolute URL paths
|
|
// (starting with /). Honors the file API's POST /op=move
|
|
// contract. Returns the new ETag.
|
|
async function moveFile(srcUrlPath, dstUrlPath, opts) {
|
|
opts = opts || {};
|
|
var headers = {
|
|
'X-ZDDC-Op': 'move',
|
|
'X-ZDDC-Destination': dstUrlPath
|
|
};
|
|
if (opts.ifMatch) headers['If-Match'] = opts.ifMatch;
|
|
var resp = await fetch(srcUrlPath, {
|
|
method: 'POST',
|
|
headers: headers,
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!resp.ok) {
|
|
var body = '';
|
|
try { body = await resp.text(); } catch (_) { /* ignore */ }
|
|
throw new Error('move ' + srcUrlPath + ' → ' + dstUrlPath + ': ' + resp.status + ' ' + body);
|
|
}
|
|
var et = resp.headers.get('ETag');
|
|
return et ? et.replace(/"/g, '') : null;
|
|
}
|
|
|
|
// Detect at construction time whether a directory handle is the
|
|
// HTTP polyfill or a real FS Access API handle. Useful for tools
|
|
// that want to take the optimized path (e.g. atomic moveFile)
|
|
// when in HTTP mode rather than the FS-API copy+remove fallback.
|
|
function isHttpHandle(handle) {
|
|
return !!(handle && handle.isHttp === true);
|
|
}
|
|
|
|
window.zddc.source = {
|
|
HttpDirectoryHandle: HttpDirectoryHandle,
|
|
HttpFileHandle: HttpFileHandle,
|
|
detectServerRoot: detectServerRoot,
|
|
moveFile: moveFile,
|
|
isHttpHandle: isHttpHandle,
|
|
// Lower-level helpers exposed for tools that want to call the
|
|
// server directly without going through the polyfill.
|
|
httpListing: httpListing,
|
|
joinUrl: joinUrl,
|
|
stripSlash: stripSlash
|
|
};
|
|
})();
|