test,docs(zip): browse/archive zip-transmittal coverage + fixture + docs

- tests/browse.spec.js: expand a .zip in the file tree (offline), drill
  into a member subdir, preview a text member — exercises shared/zip-source.js
  and the migrated offline path end to end.
- tests/archive.spec.js: a .zip whose name parses as a transmittal folder
  is scanned like an uncompressed one — members land in the file list with
  tracking numbers parsed, tied to the zip transmittal's folder.
- tests/fixtures/mock-fs-api.js: __setMockDirectoryTree now keeps binary
  leaf values (Uint8Array/ArrayBuffer/Blob) intact instead of String()-ing
  them — needed to feed real zip bytes through the mock FS.
- tests/data/test-archive.sh: each party gets one transmittal delivered as
  a single .zip in received/, so the bitnest fixture exercises the
  zip-as-virtual-directory path.
- ARCHITECTURE.md / AGENTS.md: document .zip-as-navigable-directory (server
  route + ACL model + shared client adapter + the one-level nesting limit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-12 12:35:48 -05:00
parent 2dc2d032a0
commit db1f44cf74
6 changed files with 128 additions and 1 deletions

View file

@ -491,6 +491,8 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
**Audit log captures the as-typed path.** `AccessLogMiddleware` snapshots `r.URL.Path` before dispatch rewrites it; the audit record's `path` field is what the client sent. When canonicalization changed it, a `resolved_path` field is added. **Audit log captures the as-typed path.** `AccessLogMiddleware` snapshots `r.URL.Path` before dispatch rewrites it; the audit record's `path` field is what the client sent. When canonicalization changed it, a `resolved_path` field is added.
**`.zip` files are navigable directories.** `GET …/Foo.zip/` → JSON listing of the zip's members (or browse HTML); `GET …/Foo.zip/sub/doc.pdf` → that one member, extracted and streamed (Range/ETag via `http.ServeContent`); `GET …/Foo.zip` (no slash) → the raw `.zip` download, unchanged. Write methods to a path inside a `.zip` → 405 (read-only). ACL = the chain of the directory *containing* the zip (a zip has no `.zddc`, like `.archive`). Code: `internal/zipfs` (member listing/extraction with a zip-slip guard) + `handler.ServeZip`, routed by `splitZipPath` in `dispatch` *before* the file-API branch (gated by a cheap `.zip/` substring check so ordinary requests don't pay an `os.Stat`-per-segment walk). Client-side, `shared/zip-source.js` (`ZipDirectoryHandle`/`ZipFileHandle` over JSZip) gives the archive and browse tools the same navigation offline. Archive treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder (`isTransmittalFolderZip` in `archive/js/parser.js`); browse expands any `.zip`. Nested zips: the server serves one level (`…/Foo.zip/inner.zip` is the inner zip's bytes; `…/Foo.zip/inner.zip/` isn't a listing) — clients that need deeper nesting fetch the inner zip whole and recurse with JSZip.
### Client mode (proxy / cache / mirror) ### Client mode (proxy / cache / mirror)
When `--upstream <url>` is set, the binary runs as a **downstream client** of another zddc-server instead of a master. `cmd/zddc-server/main.go` short-circuits to `runClient(cfg)`, which builds a `*cache.Cache` from `zddc/internal/cache/` and uses it as the entire request handler — no archive index, no apps server, no watcher, no OPA decider, no ACL middleware, no token store. When `--upstream <url>` is set, the binary runs as a **downstream client** of another zddc-server instead of a master. `cmd/zddc-server/main.go` short-circuits to `runClient(cfg)`, which builds a `*cache.Cache` from `zddc/internal/cache/` and uses it as the entire request handler — no archive index, no apps server, no watcher, no OPA decider, no ACL middleware, no token store.

View file

@ -711,6 +711,8 @@ The schema keys that drive built-in behavior:
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side. **Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
**Zip-backed directories.** A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON listing of the zip's members (or the browse SPA for an HTML request) and `GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member — so a client navigating a zipped transmittal folder never downloads the whole archive. `GET …/Foo.zip` (no trailing slash) is unchanged: the raw `.zip` download. Read-only: `PUT`/`DELETE`/`POST` to a path inside a `.zip` is rejected (405). ACL is the chain of the directory *containing* the zip — a zip carries no `.zddc` of its own, the same model as the `.archive` virtual surface. Implemented by `internal/zipfs` + `handler.ServeZip`, routed via `splitZipPath` in the dispatcher (before the file-API branch). Offline tools (archive's scanner, browse's tree) get the same capability client-side via `shared/zip-source.js` — a `ZipDirectoryHandle`/`ZipFileHandle` pair over JSZip that mimics the File-System-Access surface. The archive tool treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder; the browse tool expands *any* `.zip`.
**WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Admins (root / subtree) bypass entirely — the escape hatch for mis-filed documents. `defaults.zddc.yaml` puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change. **WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Admins (root / subtree) bypass entirely — the escape hatch for mis-filed documents. `defaults.zddc.yaml` puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change.
**Standard roles.** `defaults.zddc.yaml` references two roles (both shipped empty — a fresh deployment grants nothing until an operator populates them): `document_controller` (read/write across a project, `rwc` at `archive/`, subtree-admin of `working/` and `staging/`, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow) and `project_team` (read-only across the project; their own `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). **Standard roles.** `defaults.zddc.yaml` references two roles (both shipped empty — a fresh deployment grants nothing until an operator populates them): `document_controller` (read/write across a project, `rwc` at `archive/`, subtree-admin of `working/` and `staging/`, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow) and `project_team` (read-only across the project; their own `working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule).

View file

@ -87,6 +87,56 @@ test.describe('Archive Browser', () => {
expect(fileCountText).toBeTruthy(); expect(fileCountText).toBeTruthy();
}); });
test('a .zip transmittal folder is scanned like an uncompressed one', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
// One plain transmittal folder + one delivered as a single .zip
// whose name parses as a transmittal-folder name. The zip's
// members should land in the file list exactly like the plain
// folder's files. (JSZip is bundled into archive.html.)
await page.evaluate(async () => {
const zip = new window.JSZip();
zip.file('789012-ME-CAL-0001_A (IFA) - Calculation.pdf', '%PDF calc');
zip.file('sub/789012-ME-DRW-0001_A (IFA) - Detail.dwg', 'DWG detail');
const buf = await zip.generateAsync({ type: 'uint8array' });
window.__setMockDirectoryTree('test-project', {
'2025-01-15_123456-EM-TRN-0001 (IFC) - First Transmittal': {
'123456-EL-SPC-2623_A (IFC) - Specification.pdf': '%PDF',
},
'2025-02-10_123456-EM-TRN-0002 (IFC) - Second Transmittal.zip': buf,
});
});
await page.locator('#addDirectoryBtn').click();
await page.waitForFunction(() => window.app && Array.isArray(window.app.files) && window.app.files.length >= 3, { timeout: 10000 });
// 1 file from the plain folder + 2 from inside the zip.
const fileCount = await page.evaluate(() => window.app.files.length);
expect(fileCount).toBeGreaterThanOrEqual(3);
// The zip is surfaced as a transmittal folder, named without ".zip".
const zipFolder = await page.evaluate(() =>
window.app.transmittalFolders.find(f => /Second Transmittal$/.test(f.name)) || null);
expect(zipFolder).toBeTruthy();
expect(zipFolder.name).not.toMatch(/\.zip$/i);
// The zip's members are parsed like normal archive files (tracking
// numbers extracted) and tied to the zip transmittal's folder.
const memberTracking = await page.evaluate(() =>
window.app.files.filter(f => f.folderPath && /\.zip$/i.test(f.folderPath)).map(f => f.trackingNumber).sort());
expect(memberTracking).toEqual(['789012-ME-CAL-0001', '789012-ME-DRW-0001']);
// Select all grouping folders + render the table; zip members show.
await page.evaluate(() => {
const cb = document.getElementById('selectAllGroupingCheckbox');
if (cb && !cb.checked) cb.click();
});
await page.waitForTimeout(300);
const rowCount = await page.locator('#filesTableBody tr').count();
expect(rowCount).toBeGreaterThanOrEqual(3);
});
test('Mode 1: ?projects=A,B enters each project\'s Archive subfolder', async ({ page }) => { test('Mode 1: ?projects=A,B enters each project\'s Archive subfolder', async ({ page }) => {
// Multi-project layout: server root holds project folders, each containing an // Multi-project layout: server root holds project folders, each containing an
// Archive/ subfolder with third-party folders. ?projects=A,B (set as // Archive/ subfolder with third-party folders. ?projects=A,B (set as

View file

@ -105,4 +105,46 @@ test.describe('Browse', () => {
// Source hint reflects local FS-API mode. // Source hint reflects local FS-API mode.
await expect(page.locator('.md-shell__source')).toHaveText(/local/i); await expect(page.locator('.md-shell__source')).toHaveText(/local/i);
}); });
test('expands a .zip transmittal folder and previews a member', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
// Build a real zip in-browser (JSZip is bundled into browse.html)
// and hand its bytes to the mock FS as a single .zip file whose
// name parses as a transmittal folder.
await page.evaluate(async () => {
const zip = new window.JSZip();
zip.file('DOC-001 (IFI) - Spec.pdf', '%PDF-1.4 fixture body');
zip.file('sub/note.txt', 'a note inside the zip');
const buf = await zip.generateAsync({ type: 'uint8array' });
window.__setMockDirectory('PartyA', [
{ name: '2025-05-12_DOC-001 (IFI) - Title.zip', content: buf, size: buf.length },
]);
});
await page.locator('#addDirectoryBtn').click();
await page.waitForSelector('#treeBody .tree-row[data-iszip="true"]', { timeout: 10000 });
// Expand the .zip — it should list its members like a folder.
await page.locator('#treeBody .tree-row[data-iszip="true"]').first().click();
await page.waitForFunction(
() => document.querySelectorAll('#treeBody .tree-row').length >= 3,
{ timeout: 10000 }
);
const labels = await page.locator('#treeBody .tree-name__label').allTextContents();
expect(labels.join('|')).toContain('DOC-001 (IFI) - Spec.pdf');
expect(labels.join('|')).toContain('sub');
// Drill into the subdir, then preview the text member.
await page.locator('#treeBody .tree-row[data-isdir="true"]').last().click();
await page.waitForFunction(
() => Array.from(document.querySelectorAll('#treeBody .tree-name__label'))
.some(el => el.textContent === 'note.txt'),
{ timeout: 10000 }
);
const noteRow = page.locator('#treeBody .tree-row', { has: page.locator('.tree-name__label', { hasText: /^note\.txt$/ }) });
await noteRow.click();
await expect(page.locator('#previewTitle')).toHaveText(/note\.txt/);
await expect(page.locator('#previewBody')).toContainText('a note inside the zip');
});
}); });

View file

@ -517,6 +517,32 @@ cmd_build() {
fi fi
done done
done done
# One transmittal delivered as a single .zip (rather than an
# uncompressed folder): archive treats it like a normal
# transmittal folder, and zddc-server serves "<…>.zip/" as a
# virtual directory whose members are extracted on demand.
z_track=$(make_tracking "$party")
z_status=$(pick_word "$STATUSES")
z_title=$(random_title)
z_date=$(random_date)
z_dest="$party_dir/received/${z_date}_${z_track} (${z_status}) - ${z_title}.zip"
z_tmp=$(mktemp -d)
for z_ext in pdf md yaml; do
m_track=$(make_tracking "$party")
m_rev=$(pick_word "$REVISIONS")
m_status=$(pick_word "$STATUSES")
m_title=$(random_title)
m_name="${m_track}_${m_rev} (${m_status}) - ${m_title}.${z_ext}"
render_file "$z_ext" "$m_track" "$m_rev" "$m_status" "$m_title" "$z_tmp/$m_name"
file_count=$((file_count + 1))
if [ "$z_ext" = "pdf" ]; then
pdf_count=$((pdf_count + 1))
fi
done
( cd "$z_tmp" && zip -q "$z_dest" ./* )
rm -rf "$z_tmp"
chmod 0666 "$z_dest" 2>/dev/null || true
done done
done done

View file

@ -149,7 +149,12 @@ export const MOCK_FS_INIT_SCRIPT = `
function buildEntries(obj) { function buildEntries(obj) {
const entries = []; const entries = [];
for (const [name, value] of Object.entries(obj)) { for (const [name, value] of Object.entries(obj)) {
if (typeof value === 'object' && value !== null && !ArrayBuffer.isView(value)) { if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer || value instanceof Blob) {
// Binary file content (e.g. a zip's bytes) — keep it intact;
// String()-ing a Uint8Array would corrupt it.
const len = value.byteLength != null ? value.byteLength : (value.size || 0);
entries.push(new MockFileHandle(name, value, len));
} else if (typeof value === 'object' && value !== null) {
entries.push(new MockDirectoryHandle(name, buildEntries(value))); entries.push(new MockDirectoryHandle(name, buildEntries(value)));
} else { } else {
entries.push(new MockFileHandle(name, String(value), String(value).length)); entries.push(new MockFileHandle(name, String(value), String(value).length));