Compare commits
7 commits
8875d490f5
...
b59a7f6100
| Author | SHA1 | Date | |
|---|---|---|---|
| b59a7f6100 | |||
| 29182480c2 | |||
| f94defc8c1 | |||
| bee36c2ee9 | |||
| 150da9d186 | |||
| 7d462ab7a4 | |||
| db110665f0 |
49 changed files with 1705 additions and 3108 deletions
|
|
@ -255,10 +255,10 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Derive the party from the path: archive/<party>/incoming/<folder>/.
|
||||
// Derive the party from the path: incoming/<party>/<folder>/.
|
||||
var parts = url.replace(/^\/+|\/+$/g, '').split('/');
|
||||
var partyIdx = parts.indexOf('archive');
|
||||
var party = (partyIdx >= 0 && parts[partyIdx + 1]) ? parts[partyIdx + 1] : '';
|
||||
var incIdx = parts.indexOf('incoming');
|
||||
var party = (incIdx >= 0 && parts[incIdx + 1]) ? parts[incIdx + 1] : '';
|
||||
|
||||
var classification = classifyChildren(node, parsedFolder.trackingNumber);
|
||||
|
||||
|
|
|
|||
|
|
@ -670,49 +670,36 @@
|
|||
// (^[A-Za-z0-9][A-Za-z0-9.-]*$).
|
||||
function validPartyName(s) { return /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(s || ''); }
|
||||
|
||||
// The project-level folder-nav aggregators. These have no physical
|
||||
// presence: <project>/<slot>/ lists the parties whose
|
||||
// archive/<party>/<slot>/ has content. Creating something here means
|
||||
// creating it under a party — see createInAggregator.
|
||||
var FOLDER_NAV_SLOTS = { working: 1, staging: 1, reviewing: 1 };
|
||||
// The party-partitioned workspace peers. Each is a physical top-level
|
||||
// directory <project>/<peer>/ whose children are <party>/ folders.
|
||||
// Creating something at a peer root means choosing a party — see
|
||||
// createInAggregator. (mdl/rsk rows are created via the tables tool;
|
||||
// archive is the WORM record; ssr is the flat registry — none of those
|
||||
// use this picker.)
|
||||
var PARTY_PEERS = { incoming: 1, working: 1, staging: 1, reviewing: 1 };
|
||||
|
||||
// aggregatorRoot returns { project, slot } when parentDir is a
|
||||
// project-level folder-nav aggregator root (server mode only), else
|
||||
// null. parentDir is a "/<project>/<slot>/" URL.
|
||||
// aggregatorRoot returns { project, slot } when parentDir is a party-
|
||||
// partitioned peer root (server mode only), else null. parentDir is a
|
||||
// "/<project>/<peer>/" URL.
|
||||
function aggregatorRoot(parentDir) {
|
||||
if (state.source !== 'server') return null;
|
||||
var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/');
|
||||
if (segs.length !== 2 || !segs[0]) return null;
|
||||
var slot = segs[1].toLowerCase();
|
||||
return FOLDER_NAV_SLOTS[slot] ? { project: segs[0], slot: slot } : null;
|
||||
var peer = segs[1].toLowerCase();
|
||||
return PARTY_PEERS[peer] ? { project: segs[0], slot: peer } : null;
|
||||
}
|
||||
|
||||
// rewriteAggregatorPath maps a path UNDER a folder-nav aggregator
|
||||
// (a party already chosen — /<project>/<slot>/<party>[/<rest>]) to its
|
||||
// canonical archive path /<project>/archive/<party>/<slot>[/<rest>],
|
||||
// mirroring the server's folder-nav redirect. Returns null when
|
||||
// parentDir isn't under such an aggregator (root case is handled by
|
||||
// aggregatorRoot + the picker). Covers right-clicking a party row
|
||||
// shown in an aggregator listing so "New folder" doesn't 409.
|
||||
function rewriteAggregatorPath(parentDir) {
|
||||
if (state.source !== 'server') return null;
|
||||
var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/');
|
||||
if (segs.length < 3 || !segs[0]) return null;
|
||||
var slot = segs[1].toLowerCase();
|
||||
if (!FOLDER_NAV_SLOTS[slot]) return null;
|
||||
var p = '/' + segs[0] + '/archive/' + segs[2] + '/' + slot + '/';
|
||||
var rest = segs.slice(3);
|
||||
if (rest.length) p += rest.join('/') + '/';
|
||||
return p;
|
||||
}
|
||||
|
||||
// List the parties under a project's archive/ (folder names), sorted.
|
||||
// List the registered parties for a project — one ssr/<party>.yaml per
|
||||
// party (the authoritative registry). A party "exists" iff its ssr row
|
||||
// exists, so this is the canonical source for the picker. Returns []
|
||||
// on error.
|
||||
async function fetchParties(project) {
|
||||
try {
|
||||
var entries = await loader.fetchServerChildren('/' + project + '/archive/');
|
||||
var entries = await loader.fetchServerChildren('/' + project + '/ssr/');
|
||||
return entries
|
||||
.filter(function (e) { return e.isDir; })
|
||||
.map(function (e) { return e.name; })
|
||||
.filter(function (e) { return !e.isDir && /\.yaml$/i.test(e.name); })
|
||||
.map(function (e) { return e.name.replace(/\.yaml$/i, ''); })
|
||||
.filter(function (n) { return n !== 'table' && n !== 'form'; })
|
||||
.sort(function (a, b) { return a.localeCompare(b); });
|
||||
} catch (_e) { return []; }
|
||||
}
|
||||
|
|
@ -738,8 +725,8 @@
|
|||
box.innerHTML =
|
||||
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">New ' + kindWord + ' in ' + escapeHtml(opts.slot) + '/</h2>' +
|
||||
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
|
||||
escapeHtml(opts.slot) + '/ aggregates each party’s work, so it has no folder of its own. ' +
|
||||
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>archive/<party>/' + escapeHtml(opts.slot) + '/</code>.' +
|
||||
escapeHtml(opts.slot) + '/ is partitioned by party. ' +
|
||||
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/<party>/</code>.' +
|
||||
'</p>' +
|
||||
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
|
||||
(partyList || '<em style="color:#888;">No parties yet — create one below.</em>') +
|
||||
|
|
@ -790,13 +777,15 @@
|
|||
var nv = validateName(box.querySelector('#pp-name').value);
|
||||
if (!nv.ok) { statusError(nv.msg); return; }
|
||||
close();
|
||||
resolve({ party: party, name: nv.name });
|
||||
resolve({ party: party, name: nv.name, isNew: sel.value === '__new__' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// createInAggregator routes a New folder/file in a virtual aggregator
|
||||
// root to archive/<party>/<slot>/<name> after prompting for the party.
|
||||
// createInAggregator routes a New folder/file at a party-peer root to
|
||||
// the physical <project>/<peer>/<party>/<name> after prompting for the
|
||||
// party. A brand-new party is registered first by creating its
|
||||
// ssr/<party>.yaml row (the authoritative registry; party_source: ssr).
|
||||
async function createInAggregator(agg, kind) {
|
||||
var up = window.app.modules.upload;
|
||||
if (!up) return;
|
||||
|
|
@ -805,42 +794,45 @@
|
|||
if (!choice) return;
|
||||
// Party names are validated to a URL-safe charset, so no encoding
|
||||
// needed for the party segment; makeDir/makeFile encode the leaf.
|
||||
var targetDir = '/' + agg.project + '/archive/' + choice.party + '/' + agg.slot + '/';
|
||||
var targetDir = '/' + agg.project + '/' + agg.slot + '/' + choice.party + '/';
|
||||
try {
|
||||
if (choice.isNew) {
|
||||
// Register the party: its existence is ssr/<party>.yaml.
|
||||
await up.makeFile('/' + agg.project + '/ssr/', choice.party + '.yaml',
|
||||
'kind: SSR\n', 'application/yaml; charset=utf-8');
|
||||
}
|
||||
if (kind === 'folder') {
|
||||
await up.makeDir(targetDir, choice.name);
|
||||
statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + choice.name);
|
||||
statusInfo('Created ' + choice.party + '/' + choice.name + ' in ' + agg.slot + '/');
|
||||
} else {
|
||||
var name = /\.(md|markdown)$/i.test(choice.name) ? choice.name : choice.name + '.md';
|
||||
var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n';
|
||||
await up.makeFile(targetDir, name, template, 'text/markdown; charset=utf-8');
|
||||
statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + name);
|
||||
statusInfo('Created ' + choice.party + '/' + name + ' in ' + agg.slot + '/');
|
||||
}
|
||||
} catch (e) {
|
||||
var msg = (e && e.message) || String(e);
|
||||
if (/\b403\b/.test(msg)) {
|
||||
statusError('Not allowed — creating a new party requires the document-controller role.');
|
||||
statusError('Not allowed — registering a new party requires the document-controller role.');
|
||||
} else if (/\b409\b/.test(msg)) {
|
||||
statusError('Unknown party — register it first (document controller).');
|
||||
} else {
|
||||
statusError('Create failed: ' + msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Refresh the aggregator view — the party now appears if it had no
|
||||
// content before.
|
||||
await reloadDir('/' + agg.project + '/' + agg.slot + '/');
|
||||
}
|
||||
|
||||
async function createInDir(parentDir, kind) {
|
||||
var up = window.app.modules.upload;
|
||||
if (!up) return;
|
||||
// A project-level folder-nav aggregator (working/staging/reviewing)
|
||||
// has no physical home — route through the party picker instead of
|
||||
// erroring on an unplaceable mkdir/PUT.
|
||||
// At a party-peer root (incoming/working/staging/reviewing) the
|
||||
// create needs a party — route through the picker. Deeper paths
|
||||
// (a party already chosen, e.g. working/<party>/…) are physical and
|
||||
// created directly.
|
||||
var agg = aggregatorRoot(parentDir);
|
||||
if (agg) return createInAggregator(agg, kind);
|
||||
// A party already chosen inside an aggregator view → canonical path.
|
||||
var rewritten = rewriteAggregatorPath(parentDir);
|
||||
if (rewritten) parentDir = rewritten;
|
||||
var promptMsg = kind === 'folder'
|
||||
? 'New folder name (under ' + parentDir + '):'
|
||||
: 'New markdown filename (under ' + parentDir + '):';
|
||||
|
|
|
|||
|
|
@ -1,21 +1,18 @@
|
|||
// stage.js — Stage and Unstage workflow modals.
|
||||
//
|
||||
// After the layout reshape, working/ and staging/ live INSIDE each
|
||||
// party folder: archive/<party>/working/<email>/<file> and
|
||||
// archive/<party>/staging/<batch>/<file>. Stage and Unstage are now
|
||||
// per-party — the destination batch is always inside the SAME
|
||||
// party's staging slot. The party context is read from the source
|
||||
// file's path.
|
||||
// In the flat-peer layout working/ and staging/ are top-level peers,
|
||||
// each partitioned by party: working/<party>/<file> and
|
||||
// staging/<party>/<batch>/<file>. Stage and Unstage are per-party — the
|
||||
// destination batch is always inside the SAME party's staging peer. The
|
||||
// party context is read from the source file's path.
|
||||
//
|
||||
// Stage: move a file from archive/<party>/working/<…> into a
|
||||
// transmittal folder under archive/<party>/staging/<…>. Modal lists
|
||||
// existing transmittal folders in the party's staging/ plus a "New
|
||||
// transmittal folder…" option that prompts for a ZDDC-conforming
|
||||
// name and mkdirs it before the move.
|
||||
// Stage: move a file from working/<party>/<…> into a transmittal folder
|
||||
// under staging/<party>/<…>. Modal lists existing transmittal folders in
|
||||
// the party's staging/ plus a "New transmittal folder…" option that
|
||||
// prompts for a ZDDC-conforming name and mkdirs it before the move.
|
||||
//
|
||||
// Unstage: move a file from archive/<party>/staging/<transmittal>/
|
||||
// back to the user's archive/<party>/working/<email>/ home
|
||||
// (overridable).
|
||||
// Unstage: move a file from staging/<party>/<transmittal>/ back to
|
||||
// working/<party>/ (overridable).
|
||||
//
|
||||
// Both reuse the existing X-ZDDC-Op: move primitive — no new composite
|
||||
// endpoint is needed; the client just orchestrates one POST per file
|
||||
|
|
@ -36,19 +33,21 @@
|
|||
|
||||
// ── Scope detection: path-shape, not cascade-content ──────────────
|
||||
// A file is stageable if its path matches
|
||||
// /<project>/archive/<party>/working/<…>. Unstageable if it
|
||||
// matches /<project>/archive/<party>/staging/<transmittal>/<…>.
|
||||
// Both are path-shape queries — content/ACL is enforced server-
|
||||
// side.
|
||||
// /<project>/working/<party>/<…>. Unstageable if it matches
|
||||
// /<project>/staging/<party>/<transmittal>/<…>. Both are path-shape
|
||||
// queries — content/ACL is enforced server-side.
|
||||
|
||||
// projectPartySlot returns { project, party, slot, rest } when
|
||||
// path matches /<project>/archive/<party>/<slot>/<rest…>, or
|
||||
var WORKSPACE_PEERS = { working: 1, staging: 1, reviewing: 1, incoming: 1 };
|
||||
|
||||
// projectPartySlot returns { project, party, slot, rest } when path
|
||||
// matches /<project>/<slot>/<party>/<rest…> for a workspace peer, or
|
||||
// null on non-match.
|
||||
function projectPartySlot(path) {
|
||||
var rel = path.replace(/^\/+|\/+$/g, '').split('/');
|
||||
if (rel.length < 4) return null;
|
||||
if (rel[1].toLowerCase() !== 'archive') return null;
|
||||
return { project: rel[0], party: rel[2], slot: rel[3], rest: rel.slice(4) };
|
||||
if (rel.length < 3) return null;
|
||||
var slot = rel[1].toLowerCase();
|
||||
if (!WORKSPACE_PEERS[slot]) return null;
|
||||
return { project: rel[0], slot: slot, party: rel[2], rest: rel.slice(3) };
|
||||
}
|
||||
|
||||
function isStageableFile(node) {
|
||||
|
|
@ -85,7 +84,7 @@
|
|||
|
||||
async function fetchStagingFolders(project, party) {
|
||||
var entries = await listDir(
|
||||
'/' + project + '/archive/' + encodeURIComponent(party) + '/staging/');
|
||||
'/' + project + '/staging/' + encodeURIComponent(party) + '/');
|
||||
return entries
|
||||
.filter(function (e) { return e && e.isDir; })
|
||||
.map(function (e) { return e.name; });
|
||||
|
|
@ -273,11 +272,11 @@
|
|||
var srcUrl = tree.pathFor(node);
|
||||
var info = projectPartySlot(srcUrl);
|
||||
if (!info || info.slot !== 'working') {
|
||||
status('Stage applies only to files under archive/<party>/working/.', 'error');
|
||||
status('Stage applies only to files under working/<party>/.', 'error');
|
||||
return;
|
||||
}
|
||||
var stagingBase = '/' + info.project + '/archive/' +
|
||||
encodeURIComponent(info.party) + '/staging/';
|
||||
var stagingBase = '/' + info.project + '/staging/' +
|
||||
encodeURIComponent(info.party) + '/';
|
||||
var folders;
|
||||
try { folders = await fetchStagingFolders(info.project, info.party); }
|
||||
catch (e) {
|
||||
|
|
@ -315,12 +314,11 @@
|
|||
var srcUrl = tree.pathFor(node);
|
||||
var info = projectPartySlot(srcUrl);
|
||||
if (!info || info.slot !== 'staging') {
|
||||
status('Unstage applies only to files under archive/<party>/staging/.', 'error');
|
||||
status('Unstage applies only to files under staging/<party>/.', 'error');
|
||||
return;
|
||||
}
|
||||
var email = await fetchSelfEmail();
|
||||
var defaultTarget = '/' + info.project + '/archive/' +
|
||||
encodeURIComponent(info.party) + '/working/' + (email || '') + '/';
|
||||
var defaultTarget = '/' + info.project + '/working/' +
|
||||
encodeURIComponent(info.party) + '/';
|
||||
var choice;
|
||||
try {
|
||||
choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget });
|
||||
|
|
|
|||
143
scripts/migrate-toplevel-peers.sh
Executable file
143
scripts/migrate-toplevel-peers.sh
Executable file
|
|
@ -0,0 +1,143 @@
|
|||
#!/bin/sh
|
||||
# migrate-toplevel-peers.sh — migrate a ZDDC root from the old
|
||||
# "everything under archive/<party>/" layout to the flat top-level
|
||||
# party-peer layout.
|
||||
#
|
||||
# Per project (a top-level dir containing archive/), for each
|
||||
# archive/<party>/:
|
||||
# - move archive/<party>/{incoming,working,staging,reviewing,mdl,rsk}
|
||||
# → <project>/<slot>/<party>/ (the workspace/register peers)
|
||||
# - move archive/<party>/ssr.yaml → <project>/ssr/<party>.yaml
|
||||
# (this establishes the party registry — a party exists iff its
|
||||
# ssr/<party>.yaml exists)
|
||||
# - if a party has no ssr.yaml, synthesize a minimal ssr/<party>.yaml
|
||||
# so it stays registered (else its workspaces can't be recreated)
|
||||
# - LEAVE archive/<party>/{received,issued} in place (the WORM record)
|
||||
#
|
||||
# Per-folder .zddc files travel with their directory (the whole slot dir
|
||||
# is moved). Idempotent: already-migrated paths are skipped. Run with the
|
||||
# server stopped (or accept it's a plain filesystem move).
|
||||
#
|
||||
# Usage:
|
||||
# migrate-toplevel-peers.sh [--dry-run] <ZDDC_ROOT>
|
||||
#
|
||||
# --dry-run prints what would happen and changes nothing.
|
||||
|
||||
set -eu
|
||||
|
||||
DRY=0
|
||||
ROOT=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
-*)
|
||||
echo "unknown flag: $arg" >&2
|
||||
exit 2
|
||||
;;
|
||||
*) ROOT="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$ROOT" ] || [ ! -d "$ROOT" ]; then
|
||||
echo "usage: $0 [--dry-run] <ZDDC_ROOT>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
WORKSPACE_SLOTS="incoming working staging reviewing mdl rsk"
|
||||
moved=0
|
||||
synth=0
|
||||
skipped=0
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
act() {
|
||||
# act <description> <command...>
|
||||
desc=$1
|
||||
shift
|
||||
if [ "$DRY" -eq 1 ]; then
|
||||
say "DRY $desc"
|
||||
else
|
||||
say " -> $desc"
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# move_dir SRC DST — move a directory, creating DST's parent. Skips when
|
||||
# SRC is absent (nothing to do) or DST already exists (already migrated).
|
||||
move_dir() {
|
||||
src=$1
|
||||
dst=$2
|
||||
[ -d "$src" ] || return 0
|
||||
if [ -e "$dst" ]; then
|
||||
say " .. skip (dest exists): $dst"
|
||||
skipped=$((skipped + 1))
|
||||
return 0
|
||||
fi
|
||||
parent=$(dirname "$dst")
|
||||
act "mkdir -p $parent" mkdir -p "$parent"
|
||||
act "mv $src -> $dst" mv "$src" "$dst"
|
||||
moved=$((moved + 1))
|
||||
}
|
||||
|
||||
# move_file SRC DST — same shape, for the ssr.yaml registry row.
|
||||
move_file() {
|
||||
src=$1
|
||||
dst=$2
|
||||
[ -f "$src" ] || return 0
|
||||
if [ -e "$dst" ]; then
|
||||
say " .. skip (dest exists): $dst"
|
||||
skipped=$((skipped + 1))
|
||||
return 0
|
||||
fi
|
||||
parent=$(dirname "$dst")
|
||||
act "mkdir -p $parent" mkdir -p "$parent"
|
||||
act "mv $src -> $dst" mv "$src" "$dst"
|
||||
moved=$((moved + 1))
|
||||
}
|
||||
|
||||
synth_registry() {
|
||||
dst=$1
|
||||
[ -e "$dst" ] && return 0
|
||||
parent=$(dirname "$dst")
|
||||
act "mkdir -p $parent" mkdir -p "$parent"
|
||||
if [ "$DRY" -eq 1 ]; then
|
||||
say "DRY synthesize $dst (kind: SSR)"
|
||||
else
|
||||
say " -> synthesize $dst (kind: SSR)"
|
||||
printf 'kind: SSR\n' >"$dst"
|
||||
fi
|
||||
synth=$((synth + 1))
|
||||
}
|
||||
|
||||
for projectdir in "$ROOT"/*/; do
|
||||
[ -d "$projectdir/archive" ] || continue
|
||||
project=$(basename "$projectdir")
|
||||
case "$project" in .* | _*) continue ;; esac
|
||||
|
||||
say "project: $project"
|
||||
for partydir in "$projectdir"archive/*/; do
|
||||
[ -d "$partydir" ] || continue
|
||||
party=$(basename "$partydir")
|
||||
case "$party" in .* | _*) continue ;; esac
|
||||
say " party: $party"
|
||||
|
||||
# 1. Registry row: archive/<party>/ssr.yaml -> ssr/<party>.yaml
|
||||
move_file "${partydir}ssr.yaml" "${projectdir}ssr/${party}.yaml"
|
||||
|
||||
# 2. Workspace/register slots -> <slot>/<party>/
|
||||
for slot in $WORKSPACE_SLOTS; do
|
||||
move_dir "${partydir}${slot}" "${projectdir}${slot}/${party}"
|
||||
done
|
||||
|
||||
# 3. Guarantee a registry entry so the party stays registered.
|
||||
synth_registry "${projectdir}ssr/${party}.yaml"
|
||||
done
|
||||
done
|
||||
|
||||
say ""
|
||||
say "summary: moved=$moved synthesized=$synth skipped=$skipped"
|
||||
[ "$DRY" -eq 1 ] && say "(dry-run — nothing changed)"
|
||||
exit 0
|
||||
|
|
@ -180,34 +180,58 @@
|
|||
return cur;
|
||||
}
|
||||
|
||||
function isRowFile(name) {
|
||||
return name.endsWith('.yaml') && name !== 'table.yaml' && name !== 'form.yaml';
|
||||
}
|
||||
|
||||
// readRows reads a table's rows from rowsDir. A flat directory
|
||||
// (a per-party register like mdl/<party>/ or the ssr/ registry)
|
||||
// yields one row per *.yaml file. An aggregate peer root (mdl/ ,
|
||||
// rsk/) instead contains party SUBDIRS — we recurse ONE level so the
|
||||
// peer root renders the cross-party table. relName carries the
|
||||
// <party>/ prefix for those rows so reads + edit URLs hit the real
|
||||
// per-party path; $party is derived from that prefix (and matches the
|
||||
// server-injected value online). Works in both online + offline modes.
|
||||
async function readRows(rowsDir, _rowsRel, _tableName) {
|
||||
const rows = [];
|
||||
for await (const entry of rowsDir.values()) {
|
||||
if (entry.kind !== 'file') continue;
|
||||
if (!entry.name.endsWith('.yaml')) continue;
|
||||
// Skip the spec and the row-edit form — they live alongside
|
||||
// the rows but aren't rows themselves.
|
||||
if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
|
||||
|
||||
async function pushRow(handle, relName) {
|
||||
try {
|
||||
const handle = await rowsDir.getFileHandle(entry.name);
|
||||
const file = await handle.getFile();
|
||||
const data = window.jsyaml.load(await file.text());
|
||||
const data = window.jsyaml.load(await file.text()) || {};
|
||||
const slash = relName.indexOf('/');
|
||||
if (slash > 0 && typeof data === 'object' && data.$party === undefined) {
|
||||
data['$party'] = relName.slice(0, slash);
|
||||
}
|
||||
rows.push({
|
||||
url: rowEditUrl(entry.name),
|
||||
// Underlying YAML URL — strip the trailing .html
|
||||
// from the form-mode re-edit URL. Phase 3 PUTs to
|
||||
// this URL with If-Match: <etag> for optimistic
|
||||
// concurrency.
|
||||
yamlUrl: rowEditUrl(entry.name).replace(/\.html$/, ''),
|
||||
data: data || {},
|
||||
// ETag captured by HttpFileHandle.getFile from the
|
||||
// server's response header. null in offline / file://
|
||||
// mode (no HTTP roundtrip happened).
|
||||
url: rowEditUrl(relName),
|
||||
// Underlying YAML URL — strip the trailing .html from
|
||||
// the form-mode re-edit URL. PUTs go here with
|
||||
// If-Match: <etag> for optimistic concurrency.
|
||||
yamlUrl: rowEditUrl(relName).replace(/\.html$/, ''),
|
||||
data: data,
|
||||
etag: handle._etag || null,
|
||||
editable: true
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[tables] skipping unparseable row', entry.name, err);
|
||||
console.warn('[tables] skipping unparseable row', relName, err);
|
||||
}
|
||||
}
|
||||
|
||||
for await (const entry of rowsDir.values()) {
|
||||
if (entry.kind === 'file') {
|
||||
if (!isRowFile(entry.name)) continue;
|
||||
await pushRow(await rowsDir.getFileHandle(entry.name), entry.name);
|
||||
continue;
|
||||
}
|
||||
if (entry.kind === 'directory') {
|
||||
let sub;
|
||||
try { sub = await rowsDir.getDirectoryHandle(entry.name); }
|
||||
catch (_e) { continue; }
|
||||
for await (const child of sub.values()) {
|
||||
if (child.kind !== 'file' || !isRowFile(child.name)) continue;
|
||||
await pushRow(await sub.getFileHandle(child.name), entry.name + '/' + child.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
|
|
|
|||
|
|
@ -156,12 +156,11 @@
|
|||
const targets = inRange ? rangeRows : [ctx.rowId];
|
||||
const items = [];
|
||||
|
||||
// Edit row — opens the schema-driven form-mode editor for
|
||||
// this row. row.url is already the <id>.yaml.html form URL
|
||||
// (the form handler unwraps virtual-view URLs server-side, so
|
||||
// SSR + rollup rows route to their per-party canonical paths
|
||||
// automatically). Disabled on multi-row range and unsaved
|
||||
// draft rows (no backing file yet).
|
||||
// Edit row — opens the schema-driven form-mode editor for this
|
||||
// row. row.url is the real <…>/<id>.yaml.html form URL (it carries
|
||||
// the <party>/ prefix for aggregate rows, so it hits the real
|
||||
// per-party path). Disabled on multi-row range and unsaved draft
|
||||
// rows (no backing file yet).
|
||||
const singleRow = targets.length === 1 ? ctx.row : null;
|
||||
const editUrl = singleRow && !singleRow.isNew && singleRow.url ? singleRow.url : null;
|
||||
items.push({
|
||||
|
|
|
|||
|
|
@ -58,11 +58,11 @@
|
|||
// contain primitive / string-array values that are safe to
|
||||
// overwrite the corresponding top-level field.
|
||||
//
|
||||
// $-prefixed keys are system-synthesised on read (e.g. `$party`
|
||||
// injected by the server's virtual-view handler on project-
|
||||
// rollup MDL/RSK rows). They are not part of the row's stored
|
||||
// YAML and would be rejected by the schema's additionalProperties
|
||||
// rule. Strip them before sending the write.
|
||||
// $-prefixed keys are system-synthesised on read (e.g. `$party`,
|
||||
// injected by the server on mdl/rsk rows or derived from the
|
||||
// party subdir in the aggregate view). They are not part of the
|
||||
// row's stored YAML and would be rejected by the schema's
|
||||
// additionalProperties rule. Strip them before sending the write.
|
||||
const merged = Object.assign({}, data || {}, drafts || {});
|
||||
for (const k of Object.keys(merged)) {
|
||||
if (k.charAt(0) === '$') delete merged[k];
|
||||
|
|
|
|||
|
|
@ -1052,9 +1052,8 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
// Default-spec fallback for the embedded table.yaml / form.yaml
|
||||
// files served when no operator file exists on disk:
|
||||
//
|
||||
// <project>/archive/<party>/{mdl,rsk}/{table,form}.yaml
|
||||
// <project>/archive/<party>/ssr.form.yaml
|
||||
// <project>/{ssr,mdl,rsk}/{table,form}.yaml
|
||||
// <project>/{ssr,mdl,rsk}/{table,form}.yaml (aggregate/registry)
|
||||
// <project>/{mdl,rsk}/<party>/{table,form}.yaml (per-party)
|
||||
//
|
||||
// The table app fetches these client-side; the fallback lets
|
||||
// a fresh project work out of the box. ACL gates against the
|
||||
|
|
@ -1078,47 +1077,13 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
}
|
||||
// Virtual project-level table views (SSR / MDL rollup / RSK
|
||||
// rollup). The virtual row URL doesn't exist on disk; the
|
||||
// underlying canonical file lives in <project>/archive/<party>/.
|
||||
// ACL evaluates against the canonical party-archive path so
|
||||
// non-owners see the row read-only and party owners can edit.
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
if vv := zddc.ResolveVirtualView(cfg.Root, urlPath); vv.Resolved && vv.Kind.IsRowKind() {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, vv.PartyArchive)
|
||||
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
handler.ServeVirtualViewRow(w, r, vv)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Virtual folder-nav redirect. URLs of the shape
|
||||
// /<project>/{working,staging,reviewing}/<party>[/...]
|
||||
// 302 to /<project>/archive/<party>/<slot>[/...] — the
|
||||
// canonical physical path. The per-party folder-nav
|
||||
// virtual itself has no on-disk presence; the redirect
|
||||
// hands the client off to the real address so subsequent
|
||||
// navigation, sharing, and bookmarks stay canonical.
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
if vv := zddc.ResolveVirtualView(cfg.Root, urlPath); vv.Resolved && vv.Kind == zddc.VirtualViewFolderNavRedir {
|
||||
target := vv.CanonicalURL
|
||||
// Preserve trailing slash from the request, since
|
||||
// the canonical URL is a directory.
|
||||
if strings.HasSuffix(urlPath, "/") && !strings.HasSuffix(target, "/") {
|
||||
target += "/"
|
||||
}
|
||||
// Preserve query string verbatim — clients
|
||||
// passing ?hidden=1 etc. should land at the same
|
||||
// query on the canonical URL.
|
||||
if q := r.URL.RawQuery; q != "" {
|
||||
target += "?" + q
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
// (Register rows are real files now — ssr/<party>.yaml and
|
||||
// mdl|rsk/<party>/<file>.yaml — so a GET of one hits the
|
||||
// on-disk serve path below, where $party/name is injected;
|
||||
// it never lands in this not-found branch. working/staging/
|
||||
// reviewing are real directories navigated normally. The old
|
||||
// virtual-row serve + folder-nav 302 are gone.)
|
||||
|
||||
// File doesn't exist at this path. Before falling through to
|
||||
// app-HTML routing or 404, check the two virtual-file-extension
|
||||
// shapes that ZDDC exposes through the listing convention:
|
||||
|
|
@ -1347,9 +1312,40 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
|
||||
// Register rows are real files; inject the path-derived source column
|
||||
// ($party for mdl/rsk rows, name for ssr rows) on read so the tables
|
||||
// tool renders it as a read-only column. The client strips it on save.
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
if field, value, ok := registerRowField(urlPath); ok {
|
||||
handler.ServeInjectedRow(w, r, absPath, field, value)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
handler.ServeFile(w, r, absPath)
|
||||
}
|
||||
|
||||
// registerRowField returns the path-derived column to inject when urlPath
|
||||
// names an aggregate register row: ($party, <party>) for
|
||||
// /<project>/{mdl,rsk}/<party>/<file>.yaml, or (name, <party>) for
|
||||
// /<project>/ssr/<party>.yaml. ok=false otherwise (incl. spec files).
|
||||
func registerRowField(urlPath string) (field, value string, ok bool) {
|
||||
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||
switch len(parts) {
|
||||
case 3:
|
||||
if parts[1] == "ssr" && strings.HasSuffix(parts[2], ".yaml") &&
|
||||
parts[2] != "table.yaml" && parts[2] != "form.yaml" {
|
||||
return "name", strings.TrimSuffix(parts[2], ".yaml"), true
|
||||
}
|
||||
case 4:
|
||||
if (parts[1] == "mdl" || parts[1] == "rsk") && strings.HasSuffix(parts[3], ".yaml") &&
|
||||
parts[3] != "table.yaml" && parts[3] != "form.yaml" {
|
||||
return "$party", parts[2], true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// runPeriodicRescan calls idx.Rebuild on `interval` until ctx is cancelled.
|
||||
// Each tick walks fsRoot from scratch and atomically replaces the live index;
|
||||
// concurrent reads are safe via the index's RWMutex. Errors are logged but do
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ func TestDispatchAppsResolution(t *testing.T) {
|
|||
}
|
||||
// Create folder convention dirs so classifier/browse/transmittal
|
||||
// availability rules pass for the test paths used below.
|
||||
mustMkdir(t, filepath.Join(root, "Project-A", "archive", "Acme", "working"))
|
||||
mustMkdir(t, filepath.Join(root, "Project-A", "working", "Acme"))
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
if err != nil {
|
||||
|
|
@ -208,9 +208,9 @@ func TestDispatchAppsResolution(t *testing.T) {
|
|||
t.Errorf("/classifier.html at root: status=%d, want 404 (not in per-party working/staging/incoming)", rec5.Code)
|
||||
}
|
||||
rec6 := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/archive/Acme/Working/classifier.html", nil))
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/working/Acme/classifier.html", nil))
|
||||
if rec6.Code != http.StatusOK {
|
||||
t.Errorf("/Project-A/archive/Acme/Working/classifier.html: status=%d, want 200", rec6.Code)
|
||||
t.Errorf("/Project-A/working/Acme/classifier.html: status=%d, want 200", rec6.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,7 +276,10 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n permissions:\n \"*@example.com\": rwcd\n")
|
||||
mustMkdir(t, filepath.Join(root, "Project-A", "archive", "Acme", "working"))
|
||||
mustMkdir(t, filepath.Join(root, "Project-A", "working", "Acme"))
|
||||
// Register the party (party_source: ssr).
|
||||
mustMkdir(t, filepath.Join(root, "Project-A", "ssr"))
|
||||
mustWrite(t, filepath.Join(root, "Project-A", "ssr", "Acme.yaml"), "kind: SSR\n")
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
if err != nil {
|
||||
|
|
@ -300,7 +303,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|||
// archive/<party>/working/ — the project-level working/ aggregator is
|
||||
// virtual (see TestFileAPI_MkdirInAggregatorRejected).
|
||||
body := []byte("note body")
|
||||
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/archive/Acme/working/note.md", strings.NewReader(string(body))), "alice@example.com")
|
||||
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/working/Acme/note.md", strings.NewReader(string(body))), "alice@example.com")
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
|
|
@ -308,7 +311,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|||
}
|
||||
|
||||
// GET it back.
|
||||
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/archive/Acme/working/note.md", nil), "alice@example.com")
|
||||
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/working/Acme/note.md", nil), "alice@example.com")
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
|
||||
|
|
@ -316,9 +319,9 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|||
}
|
||||
|
||||
// MOVE it.
|
||||
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/archive/Acme/working/note.md", nil), "alice@example.com")
|
||||
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/working/Acme/note.md", nil), "alice@example.com")
|
||||
req.Header.Set("X-ZDDC-Op", "move")
|
||||
req.Header.Set("X-ZDDC-Destination", "/Project-A/archive/Acme/working/renamed.md")
|
||||
req.Header.Set("X-ZDDC-Destination", "/Project-A/working/Acme/renamed.md")
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
|
|
@ -326,7 +329,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|||
}
|
||||
|
||||
// DELETE it.
|
||||
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/archive/Acme/working/renamed.md", nil), "alice@example.com")
|
||||
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/working/Acme/renamed.md", nil), "alice@example.com")
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
|
|
@ -670,17 +673,16 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// No-trailing-slash form on a canonical folder → default app.
|
||||
// Under the reshape, the project-root staging/reviewing/working
|
||||
// URLs are folder-nav virtuals served by browse (the per-party
|
||||
// transmittal default lives at archive/<party>/staging/). archive/
|
||||
// is still the archive tool.
|
||||
// No-trailing-slash form on a canonical peer → its default app.
|
||||
// In the flat-peer layout these are physical peers: working/reviewing
|
||||
// default to browse, staging to transmittal, archive to the archive
|
||||
// tool.
|
||||
noSlashDefaultApp := []struct {
|
||||
stage string
|
||||
expect string // substring that should appear in the response body
|
||||
}{
|
||||
{"working", "ZDDC Browse"},
|
||||
{"staging", "ZDDC Browse"},
|
||||
{"staging", "ZDDC Transmittal"},
|
||||
{"archive", "ZDDC Archive"},
|
||||
{"reviewing", "ZDDC Browse"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,48 +11,50 @@ func TestAppAvailableAt(t *testing.T) {
|
|||
dir, app string
|
||||
want bool
|
||||
}{
|
||||
// archive: everywhere
|
||||
// archive: everywhere (baseline)
|
||||
{root, "archive", true},
|
||||
{root + "/Project-A", "archive", true},
|
||||
{root + "/Project-A/working", "archive", true},
|
||||
{root + "/Project-A/working/ACME", "archive", true},
|
||||
{root + "/Project-A/some-other-folder", "archive", true},
|
||||
|
||||
// landing: only at root
|
||||
{root, "landing", true},
|
||||
{root + "/Project-A", "landing", false},
|
||||
|
||||
// classifier: per-party working/, staging/, incoming/ subtrees
|
||||
// classifier: the inbound/draft workspace peers (incoming/working/staging)
|
||||
{root, "classifier", false},
|
||||
{root + "/Project-A", "classifier", false},
|
||||
{root + "/Project-A/archive/ACME/working", "classifier", true},
|
||||
{root + "/Project-A/archive/ACME/working/deep/nested/path", "classifier", true},
|
||||
{root + "/Project-A/archive/ACME/staging", "classifier", true},
|
||||
{root + "/Project-A/archive/ACME/staging/2026-06-15_x (DFT) - y", "classifier", true},
|
||||
{root + "/Project-A/archive/ACME/incoming", "classifier", true},
|
||||
{root + "/Project-A/archive/ACME/incoming/sub", "classifier", true},
|
||||
{root + "/Project-A/working/ACME", "classifier", true},
|
||||
{root + "/Project-A/working/ACME/deep/nested/path", "classifier", true},
|
||||
{root + "/Project-A/staging/ACME", "classifier", true},
|
||||
{root + "/Project-A/staging/ACME/2026-06-15_x (DFT) - y", "classifier", true},
|
||||
{root + "/Project-A/incoming/ACME", "classifier", true},
|
||||
{root + "/Project-A/incoming/ACME/sub", "classifier", true},
|
||||
{root + "/Project-A/reviewing/ACME", "classifier", false},
|
||||
{root + "/Project-A/archive/ACME/received", "classifier", false},
|
||||
{root + "/Project-A/archive/ACME/issued", "classifier", false},
|
||||
{root + "/Project-A/archive/ACME/mdl", "classifier", false},
|
||||
{root + "/Project-A/mdl/ACME", "classifier", false},
|
||||
{root + "/Project-A/some-other-folder", "classifier", false},
|
||||
|
||||
// browse: universal — every directory has browse available
|
||||
// (it's in the embedded-defaults baseline available_tools).
|
||||
{root + "/Project-A/archive/ACME/working", "browse", true},
|
||||
{root + "/Project-A/archive/ACME/working/sub", "browse", true},
|
||||
{root + "/Project-A/archive/ACME/staging", "browse", true},
|
||||
{root + "/Project-A/archive/ACME/incoming", "browse", true},
|
||||
// browse: universal (baseline)
|
||||
{root + "/Project-A/working/ACME", "browse", true},
|
||||
{root + "/Project-A/staging/ACME", "browse", true},
|
||||
{root + "/Project-A/incoming/ACME", "browse", true},
|
||||
|
||||
// transmittal: per-party staging/ only
|
||||
{root + "/Project-A/archive/ACME/staging", "transmittal", true},
|
||||
{root + "/Project-A/archive/ACME/staging/sub", "transmittal", true},
|
||||
{root + "/Project-A/archive/ACME/working", "transmittal", false},
|
||||
// transmittal: the staging peer only
|
||||
{root + "/Project-A/staging/ACME", "transmittal", true},
|
||||
{root + "/Project-A/staging/ACME/sub", "transmittal", true},
|
||||
{root + "/Project-A/working/ACME", "transmittal", false},
|
||||
{root + "/Project-A/archive/ACME/issued", "transmittal", false},
|
||||
|
||||
// case-fold: any case of canonical names matches
|
||||
{root + "/Project-A/archive/ACME/Staging", "transmittal", true},
|
||||
{root + "/Project-A/archive/ACME/STAGING", "transmittal", true},
|
||||
{root + "/Project-A/archive/ACME/Incoming", "classifier", true},
|
||||
{root + "/Project-A/Archive/ACME/incoming", "classifier", true},
|
||||
// tables: the register peers
|
||||
{root + "/Project-A/mdl/ACME", "tables", true},
|
||||
{root + "/Project-A/rsk/ACME", "tables", true},
|
||||
{root + "/Project-A/ssr", "tables", true},
|
||||
|
||||
// case-fold: any case of a peer name matches
|
||||
{root + "/Project-A/Staging/ACME", "transmittal", true},
|
||||
{root + "/Project-A/STAGING/ACME", "transmittal", true},
|
||||
{root + "/Project-A/Incoming/ACME", "classifier", true},
|
||||
|
||||
// unknown app
|
||||
{root + "/Project-A", "weird", false},
|
||||
|
|
@ -73,45 +75,36 @@ func TestDefaultAppAt(t *testing.T) {
|
|||
dir string
|
||||
want string
|
||||
}{
|
||||
// At the deployment root itself, no default tool — landing handles
|
||||
// the project picker via a separate path.
|
||||
// Deployment root + bare project root: no default tool.
|
||||
{root, ""},
|
||||
// Bare project root: no default. Trailing-slash URL serves browse;
|
||||
// no-slash falls through to the redirect.
|
||||
{root + "/Project-A", ""},
|
||||
// Project-level virtual aggregators (sibling to archive/).
|
||||
// Top-level peers.
|
||||
{root + "/Project-A/working", "browse"},
|
||||
{root + "/Project-A/staging", "browse"},
|
||||
{root + "/Project-A/staging", "transmittal"},
|
||||
{root + "/Project-A/reviewing", "browse"},
|
||||
{root + "/Project-A/incoming", "classifier"},
|
||||
{root + "/Project-A/ssr", "tables"},
|
||||
{root + "/Project-A/mdl", "tables"},
|
||||
{root + "/Project-A/rsk", "tables"},
|
||||
// Per-party lifecycle slots (the real physical homes).
|
||||
{root + "/Project-A/archive/Acme/working", "browse"},
|
||||
{root + "/Project-A/archive/Acme/working/alice@example.com", "browse"},
|
||||
{root + "/Project-A/archive/Acme/working/2026-06-15_x (DFT) - y", "browse"},
|
||||
{root + "/Project-A/archive/Acme/staging", "transmittal"},
|
||||
{root + "/Project-A/archive/Acme/staging/2026-06-15_x (DFT) - y", "transmittal"},
|
||||
{root + "/Project-A/archive/Acme/reviewing", "browse"},
|
||||
// archive: at the archive root, party folders default to archive.
|
||||
// Per-party subfolders override per their function:
|
||||
// incoming → classifier (the bulk-rename workflow)
|
||||
// received / issued → archive (WORM record browser)
|
||||
// Per-party subdirs inherit the peer default.
|
||||
{root + "/Project-A/working/Acme", "browse"},
|
||||
{root + "/Project-A/working/Acme/2026-06-15_x (DFT) - y", "browse"},
|
||||
{root + "/Project-A/staging/Acme", "transmittal"},
|
||||
{root + "/Project-A/incoming/Acme", "classifier"},
|
||||
{root + "/Project-A/mdl/Acme", "tables"},
|
||||
{root + "/Project-A/mdl/Acme/anything-deeper", "tables"},
|
||||
{root + "/Project-A/rsk/Acme", "tables"},
|
||||
// The committed record: archive subtree → archive tool.
|
||||
{root + "/Project-A/archive", "archive"},
|
||||
{root + "/Project-A/archive/Acme", "archive"},
|
||||
{root + "/Project-A/archive/Acme/incoming", "classifier"},
|
||||
{root + "/Project-A/archive/Acme/issued", "archive"},
|
||||
{root + "/Project-A/archive/Acme/received", "archive"},
|
||||
// mdl/rsk win over the broader archive rule.
|
||||
{root + "/Project-A/archive/Acme/mdl", "tables"},
|
||||
{root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"},
|
||||
{root + "/Project-A/archive/Acme/rsk", "tables"},
|
||||
{root + "/Project-A/archive/Acme/issued", "archive"},
|
||||
// Random non-canonical folder names → no default.
|
||||
{root + "/Project-A/scratch", ""},
|
||||
// Case-fold on canonical names.
|
||||
{root + "/Project-A/archive/Acme/Working", "browse"},
|
||||
{root + "/Project-A/archive/Acme/STAGING", "transmittal"},
|
||||
{root + "/Project-A/Archive/Acme/MDL", "tables"},
|
||||
// Case-fold on peer names.
|
||||
{root + "/Project-A/Working/Acme", "browse"},
|
||||
{root + "/Project-A/STAGING/Acme", "transmittal"},
|
||||
{root + "/Project-A/MDL/Acme", "tables"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.dir, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -2582,7 +2582,7 @@ td[data-field="trackingNumber"] {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 17:46:04 · 2918248</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||
|
|
|
|||
|
|
@ -2484,7 +2484,7 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Browse</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 17:46:04 · 2918248</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||
|
|
@ -11549,10 +11549,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
return;
|
||||
}
|
||||
|
||||
// Derive the party from the path: archive/<party>/incoming/<folder>/.
|
||||
// Derive the party from the path: incoming/<party>/<folder>/.
|
||||
var parts = url.replace(/^\/+|\/+$/g, '').split('/');
|
||||
var partyIdx = parts.indexOf('archive');
|
||||
var party = (partyIdx >= 0 && parts[partyIdx + 1]) ? parts[partyIdx + 1] : '';
|
||||
var incIdx = parts.indexOf('incoming');
|
||||
var party = (incIdx >= 0 && parts[incIdx + 1]) ? parts[incIdx + 1] : '';
|
||||
|
||||
var classification = classifyChildren(node, parsedFolder.trackingNumber);
|
||||
|
||||
|
|
@ -11607,22 +11607,19 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
|
||||
// stage.js — Stage and Unstage workflow modals.
|
||||
//
|
||||
// After the layout reshape, working/ and staging/ live INSIDE each
|
||||
// party folder: archive/<party>/working/<email>/<file> and
|
||||
// archive/<party>/staging/<batch>/<file>. Stage and Unstage are now
|
||||
// per-party — the destination batch is always inside the SAME
|
||||
// party's staging slot. The party context is read from the source
|
||||
// file's path.
|
||||
// In the flat-peer layout working/ and staging/ are top-level peers,
|
||||
// each partitioned by party: working/<party>/<file> and
|
||||
// staging/<party>/<batch>/<file>. Stage and Unstage are per-party — the
|
||||
// destination batch is always inside the SAME party's staging peer. The
|
||||
// party context is read from the source file's path.
|
||||
//
|
||||
// Stage: move a file from archive/<party>/working/<…> into a
|
||||
// transmittal folder under archive/<party>/staging/<…>. Modal lists
|
||||
// existing transmittal folders in the party's staging/ plus a "New
|
||||
// transmittal folder…" option that prompts for a ZDDC-conforming
|
||||
// name and mkdirs it before the move.
|
||||
// Stage: move a file from working/<party>/<…> into a transmittal folder
|
||||
// under staging/<party>/<…>. Modal lists existing transmittal folders in
|
||||
// the party's staging/ plus a "New transmittal folder…" option that
|
||||
// prompts for a ZDDC-conforming name and mkdirs it before the move.
|
||||
//
|
||||
// Unstage: move a file from archive/<party>/staging/<transmittal>/
|
||||
// back to the user's archive/<party>/working/<email>/ home
|
||||
// (overridable).
|
||||
// Unstage: move a file from staging/<party>/<transmittal>/ back to
|
||||
// working/<party>/ (overridable).
|
||||
//
|
||||
// Both reuse the existing X-ZDDC-Op: move primitive — no new composite
|
||||
// endpoint is needed; the client just orchestrates one POST per file
|
||||
|
|
@ -11643,19 +11640,21 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
|
||||
// ── Scope detection: path-shape, not cascade-content ──────────────
|
||||
// A file is stageable if its path matches
|
||||
// /<project>/archive/<party>/working/<…>. Unstageable if it
|
||||
// matches /<project>/archive/<party>/staging/<transmittal>/<…>.
|
||||
// Both are path-shape queries — content/ACL is enforced server-
|
||||
// side.
|
||||
// /<project>/working/<party>/<…>. Unstageable if it matches
|
||||
// /<project>/staging/<party>/<transmittal>/<…>. Both are path-shape
|
||||
// queries — content/ACL is enforced server-side.
|
||||
|
||||
// projectPartySlot returns { project, party, slot, rest } when
|
||||
// path matches /<project>/archive/<party>/<slot>/<rest…>, or
|
||||
var WORKSPACE_PEERS = { working: 1, staging: 1, reviewing: 1, incoming: 1 };
|
||||
|
||||
// projectPartySlot returns { project, party, slot, rest } when path
|
||||
// matches /<project>/<slot>/<party>/<rest…> for a workspace peer, or
|
||||
// null on non-match.
|
||||
function projectPartySlot(path) {
|
||||
var rel = path.replace(/^\/+|\/+$/g, '').split('/');
|
||||
if (rel.length < 4) return null;
|
||||
if (rel[1].toLowerCase() !== 'archive') return null;
|
||||
return { project: rel[0], party: rel[2], slot: rel[3], rest: rel.slice(4) };
|
||||
if (rel.length < 3) return null;
|
||||
var slot = rel[1].toLowerCase();
|
||||
if (!WORKSPACE_PEERS[slot]) return null;
|
||||
return { project: rel[0], slot: slot, party: rel[2], rest: rel.slice(3) };
|
||||
}
|
||||
|
||||
function isStageableFile(node) {
|
||||
|
|
@ -11692,7 +11691,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
|
||||
async function fetchStagingFolders(project, party) {
|
||||
var entries = await listDir(
|
||||
'/' + project + '/archive/' + encodeURIComponent(party) + '/staging/');
|
||||
'/' + project + '/staging/' + encodeURIComponent(party) + '/');
|
||||
return entries
|
||||
.filter(function (e) { return e && e.isDir; })
|
||||
.map(function (e) { return e.name; });
|
||||
|
|
@ -11880,11 +11879,11 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
var srcUrl = tree.pathFor(node);
|
||||
var info = projectPartySlot(srcUrl);
|
||||
if (!info || info.slot !== 'working') {
|
||||
status('Stage applies only to files under archive/<party>/working/.', 'error');
|
||||
status('Stage applies only to files under working/<party>/.', 'error');
|
||||
return;
|
||||
}
|
||||
var stagingBase = '/' + info.project + '/archive/' +
|
||||
encodeURIComponent(info.party) + '/staging/';
|
||||
var stagingBase = '/' + info.project + '/staging/' +
|
||||
encodeURIComponent(info.party) + '/';
|
||||
var folders;
|
||||
try { folders = await fetchStagingFolders(info.project, info.party); }
|
||||
catch (e) {
|
||||
|
|
@ -11922,12 +11921,11 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
var srcUrl = tree.pathFor(node);
|
||||
var info = projectPartySlot(srcUrl);
|
||||
if (!info || info.slot !== 'staging') {
|
||||
status('Unstage applies only to files under archive/<party>/staging/.', 'error');
|
||||
status('Unstage applies only to files under staging/<party>/.', 'error');
|
||||
return;
|
||||
}
|
||||
var email = await fetchSelfEmail();
|
||||
var defaultTarget = '/' + info.project + '/archive/' +
|
||||
encodeURIComponent(info.party) + '/working/' + (email || '') + '/';
|
||||
var defaultTarget = '/' + info.project + '/working/' +
|
||||
encodeURIComponent(info.party) + '/';
|
||||
var choice;
|
||||
try {
|
||||
choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget });
|
||||
|
|
@ -13168,49 +13166,36 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
// (^[A-Za-z0-9][A-Za-z0-9.-]*$).
|
||||
function validPartyName(s) { return /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(s || ''); }
|
||||
|
||||
// The project-level folder-nav aggregators. These have no physical
|
||||
// presence: <project>/<slot>/ lists the parties whose
|
||||
// archive/<party>/<slot>/ has content. Creating something here means
|
||||
// creating it under a party — see createInAggregator.
|
||||
var FOLDER_NAV_SLOTS = { working: 1, staging: 1, reviewing: 1 };
|
||||
// The party-partitioned workspace peers. Each is a physical top-level
|
||||
// directory <project>/<peer>/ whose children are <party>/ folders.
|
||||
// Creating something at a peer root means choosing a party — see
|
||||
// createInAggregator. (mdl/rsk rows are created via the tables tool;
|
||||
// archive is the WORM record; ssr is the flat registry — none of those
|
||||
// use this picker.)
|
||||
var PARTY_PEERS = { incoming: 1, working: 1, staging: 1, reviewing: 1 };
|
||||
|
||||
// aggregatorRoot returns { project, slot } when parentDir is a
|
||||
// project-level folder-nav aggregator root (server mode only), else
|
||||
// null. parentDir is a "/<project>/<slot>/" URL.
|
||||
// aggregatorRoot returns { project, slot } when parentDir is a party-
|
||||
// partitioned peer root (server mode only), else null. parentDir is a
|
||||
// "/<project>/<peer>/" URL.
|
||||
function aggregatorRoot(parentDir) {
|
||||
if (state.source !== 'server') return null;
|
||||
var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/');
|
||||
if (segs.length !== 2 || !segs[0]) return null;
|
||||
var slot = segs[1].toLowerCase();
|
||||
return FOLDER_NAV_SLOTS[slot] ? { project: segs[0], slot: slot } : null;
|
||||
var peer = segs[1].toLowerCase();
|
||||
return PARTY_PEERS[peer] ? { project: segs[0], slot: peer } : null;
|
||||
}
|
||||
|
||||
// rewriteAggregatorPath maps a path UNDER a folder-nav aggregator
|
||||
// (a party already chosen — /<project>/<slot>/<party>[/<rest>]) to its
|
||||
// canonical archive path /<project>/archive/<party>/<slot>[/<rest>],
|
||||
// mirroring the server's folder-nav redirect. Returns null when
|
||||
// parentDir isn't under such an aggregator (root case is handled by
|
||||
// aggregatorRoot + the picker). Covers right-clicking a party row
|
||||
// shown in an aggregator listing so "New folder" doesn't 409.
|
||||
function rewriteAggregatorPath(parentDir) {
|
||||
if (state.source !== 'server') return null;
|
||||
var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/');
|
||||
if (segs.length < 3 || !segs[0]) return null;
|
||||
var slot = segs[1].toLowerCase();
|
||||
if (!FOLDER_NAV_SLOTS[slot]) return null;
|
||||
var p = '/' + segs[0] + '/archive/' + segs[2] + '/' + slot + '/';
|
||||
var rest = segs.slice(3);
|
||||
if (rest.length) p += rest.join('/') + '/';
|
||||
return p;
|
||||
}
|
||||
|
||||
// List the parties under a project's archive/ (folder names), sorted.
|
||||
// List the registered parties for a project — one ssr/<party>.yaml per
|
||||
// party (the authoritative registry). A party "exists" iff its ssr row
|
||||
// exists, so this is the canonical source for the picker. Returns []
|
||||
// on error.
|
||||
async function fetchParties(project) {
|
||||
try {
|
||||
var entries = await loader.fetchServerChildren('/' + project + '/archive/');
|
||||
var entries = await loader.fetchServerChildren('/' + project + '/ssr/');
|
||||
return entries
|
||||
.filter(function (e) { return e.isDir; })
|
||||
.map(function (e) { return e.name; })
|
||||
.filter(function (e) { return !e.isDir && /\.yaml$/i.test(e.name); })
|
||||
.map(function (e) { return e.name.replace(/\.yaml$/i, ''); })
|
||||
.filter(function (n) { return n !== 'table' && n !== 'form'; })
|
||||
.sort(function (a, b) { return a.localeCompare(b); });
|
||||
} catch (_e) { return []; }
|
||||
}
|
||||
|
|
@ -13236,8 +13221,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
box.innerHTML =
|
||||
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">New ' + kindWord + ' in ' + escapeHtml(opts.slot) + '/</h2>' +
|
||||
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
|
||||
escapeHtml(opts.slot) + '/ aggregates each party’s work, so it has no folder of its own. ' +
|
||||
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>archive/<party>/' + escapeHtml(opts.slot) + '/</code>.' +
|
||||
escapeHtml(opts.slot) + '/ is partitioned by party. ' +
|
||||
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/<party>/</code>.' +
|
||||
'</p>' +
|
||||
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
|
||||
(partyList || '<em style="color:#888;">No parties yet — create one below.</em>') +
|
||||
|
|
@ -13288,13 +13273,15 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
var nv = validateName(box.querySelector('#pp-name').value);
|
||||
if (!nv.ok) { statusError(nv.msg); return; }
|
||||
close();
|
||||
resolve({ party: party, name: nv.name });
|
||||
resolve({ party: party, name: nv.name, isNew: sel.value === '__new__' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// createInAggregator routes a New folder/file in a virtual aggregator
|
||||
// root to archive/<party>/<slot>/<name> after prompting for the party.
|
||||
// createInAggregator routes a New folder/file at a party-peer root to
|
||||
// the physical <project>/<peer>/<party>/<name> after prompting for the
|
||||
// party. A brand-new party is registered first by creating its
|
||||
// ssr/<party>.yaml row (the authoritative registry; party_source: ssr).
|
||||
async function createInAggregator(agg, kind) {
|
||||
var up = window.app.modules.upload;
|
||||
if (!up) return;
|
||||
|
|
@ -13303,42 +13290,45 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
if (!choice) return;
|
||||
// Party names are validated to a URL-safe charset, so no encoding
|
||||
// needed for the party segment; makeDir/makeFile encode the leaf.
|
||||
var targetDir = '/' + agg.project + '/archive/' + choice.party + '/' + agg.slot + '/';
|
||||
var targetDir = '/' + agg.project + '/' + agg.slot + '/' + choice.party + '/';
|
||||
try {
|
||||
if (choice.isNew) {
|
||||
// Register the party: its existence is ssr/<party>.yaml.
|
||||
await up.makeFile('/' + agg.project + '/ssr/', choice.party + '.yaml',
|
||||
'kind: SSR\n', 'application/yaml; charset=utf-8');
|
||||
}
|
||||
if (kind === 'folder') {
|
||||
await up.makeDir(targetDir, choice.name);
|
||||
statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + choice.name);
|
||||
statusInfo('Created ' + choice.party + '/' + choice.name + ' in ' + agg.slot + '/');
|
||||
} else {
|
||||
var name = /\.(md|markdown)$/i.test(choice.name) ? choice.name : choice.name + '.md';
|
||||
var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n';
|
||||
await up.makeFile(targetDir, name, template, 'text/markdown; charset=utf-8');
|
||||
statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + name);
|
||||
statusInfo('Created ' + choice.party + '/' + name + ' in ' + agg.slot + '/');
|
||||
}
|
||||
} catch (e) {
|
||||
var msg = (e && e.message) || String(e);
|
||||
if (/\b403\b/.test(msg)) {
|
||||
statusError('Not allowed — creating a new party requires the document-controller role.');
|
||||
statusError('Not allowed — registering a new party requires the document-controller role.');
|
||||
} else if (/\b409\b/.test(msg)) {
|
||||
statusError('Unknown party — register it first (document controller).');
|
||||
} else {
|
||||
statusError('Create failed: ' + msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Refresh the aggregator view — the party now appears if it had no
|
||||
// content before.
|
||||
await reloadDir('/' + agg.project + '/' + agg.slot + '/');
|
||||
}
|
||||
|
||||
async function createInDir(parentDir, kind) {
|
||||
var up = window.app.modules.upload;
|
||||
if (!up) return;
|
||||
// A project-level folder-nav aggregator (working/staging/reviewing)
|
||||
// has no physical home — route through the party picker instead of
|
||||
// erroring on an unplaceable mkdir/PUT.
|
||||
// At a party-peer root (incoming/working/staging/reviewing) the
|
||||
// create needs a party — route through the picker. Deeper paths
|
||||
// (a party already chosen, e.g. working/<party>/…) are physical and
|
||||
// created directly.
|
||||
var agg = aggregatorRoot(parentDir);
|
||||
if (agg) return createInAggregator(agg, kind);
|
||||
// A party already chosen inside an aggregator view → canonical path.
|
||||
var rewritten = rewriteAggregatorPath(parentDir);
|
||||
if (rewritten) parentDir = rewritten;
|
||||
var promptMsg = kind === 'folder'
|
||||
? 'New folder name (under ' + parentDir + '):'
|
||||
: 'New markdown filename (under ' + parentDir + '):';
|
||||
|
|
|
|||
|
|
@ -1793,7 +1793,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Classifier</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 17:46:04 · 2918248</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||
|
|
|
|||
|
|
@ -1536,7 +1536,7 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 17:46:04 · 2918248</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
|
|
@ -2635,7 +2635,7 @@ dialog.modal--narrow {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Transmittal</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 17:46:04 · 2918248</span></span>
|
||||
</div>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- Publish split-button (Transmittal-specific primary action;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||
archive=v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b
|
||||
transmittal=v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b
|
||||
classifier=v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b
|
||||
landing=v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b
|
||||
form=v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b
|
||||
tables=v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b
|
||||
browse=v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b
|
||||
archive=v0.0.27-beta · 2026-06-03 17:46:04 · 2918248
|
||||
transmittal=v0.0.27-beta · 2026-06-03 17:46:04 · 2918248
|
||||
classifier=v0.0.27-beta · 2026-06-03 17:46:04 · 2918248
|
||||
landing=v0.0.27-beta · 2026-06-03 17:46:04 · 2918248
|
||||
form=v0.0.27-beta · 2026-06-03 17:46:04 · 2918248
|
||||
tables=v0.0.27-beta · 2026-06-03 17:46:04 · 2918248
|
||||
browse=v0.0.27-beta · 2026-06-03 17:46:04 · 2918248
|
||||
|
|
|
|||
|
|
@ -66,8 +66,8 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
// written into them — but the cascade (defaults.zddc.yaml
|
||||
// plus any on-disk overrides) declares them via paths:, so
|
||||
// the stage-strip / file nav can link unconditionally.
|
||||
// Returning [] gives a usable empty view; the
|
||||
// virtualUserHomeEntry below still fires for working/.
|
||||
// Returning [] gives a usable empty view (the tables peers
|
||||
// still surface their synthetic spec entries below).
|
||||
if os.IsNotExist(err) && zddc.IsDeclaredPath(fsRoot, absDir) {
|
||||
entries = nil
|
||||
} else {
|
||||
|
|
@ -197,126 +197,48 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
result = append(result, fi)
|
||||
}
|
||||
|
||||
// Per-user virtual home: when listing
|
||||
// <project>/archive/<party>/working/ for an authenticated viewer,
|
||||
// surface a synthetic <viewer-email>/ entry if no real folder of
|
||||
// any case variant already exists for them. A first write to that
|
||||
// path materialises a real folder with auto-own .zddc; subsequent
|
||||
// listings drop the synthetic entry naturally.
|
||||
if syn, ok := virtualUserHomeEntry(ctx, decider, fsRoot, dirPath, principal, baseURL, result); ok {
|
||||
result = append(result, syn)
|
||||
}
|
||||
|
||||
// At a project root, surface the cascade-declared top-level
|
||||
// folders (archive plus the six virtual aggregators) as virtual
|
||||
// At a project root, surface the cascade-declared top-level peers
|
||||
// (archive + the party-partitioned workspaces/registers) as virtual
|
||||
// entries when no on-disk variant exists. The browse client
|
||||
// previously did this client-side; moving it server-side lets the
|
||||
// directory's `display:` map apply to virtual entries the same
|
||||
// way it applies to real ones.
|
||||
// directory's `display:` map apply to virtual entries the same way
|
||||
// it applies to real ones.
|
||||
result = append(result, virtualCanonicalFolders(ctx, decider, fsRoot, absDir, principal, baseURL, result, displayMap)...)
|
||||
|
||||
// Project-level virtual views:
|
||||
// Tables register peers (ssr/mdl/rsk) at the project root:
|
||||
//
|
||||
// Row rollups (ssr/mdl/rsk) — synthesize row entries (Writable
|
||||
// bit per the canonical archive/<party>/ chain) plus synthetic
|
||||
// table.yaml/form.yaml entries so the tables tool's client-side
|
||||
// walkServer finds the spec without a 404 round-trip. Spec bytes
|
||||
// come from main.go IsDefaultSpec fallback; row reads go through
|
||||
// handler.ServeVirtualViewRow which path-injects name/$party.
|
||||
//
|
||||
// Folder-nav (working/staging/reviewing) — synthesize one
|
||||
// IsDir=true entry per party whose archive/<party>/<slot>/ has
|
||||
// non-empty content (in-flight filter). The browse client
|
||||
// follows a click through to the virtual URL
|
||||
// <project>/<slot>/<party>/ which the dispatcher 302s to the
|
||||
// canonical archive/<party>/<slot>/.
|
||||
if vv := zddc.ResolveVirtualView(fsRoot, strings.TrimSuffix(baseURL, "/")); vv.Resolved && vv.Kind.IsRootKind() {
|
||||
partyChains := make(map[string]zddc.PolicyChain)
|
||||
chainFor := func(partyAbs string) zddc.PolicyChain {
|
||||
if c, ok := partyChains[partyAbs]; ok {
|
||||
return c
|
||||
}
|
||||
c, _ := zddc.EffectivePolicy(fsRoot, partyAbs)
|
||||
partyChains[partyAbs] = c
|
||||
return c
|
||||
// The peer root lists normally — mdl/ and rsk/ show their party
|
||||
// subdirs (folder-nav), ssr/ shows its flat ssr/<party>.yaml rows.
|
||||
// For mdl/rsk the tables tool aggregates the cross-party table by
|
||||
// recursing one level into the party subdirs CLIENT-side (works in
|
||||
// both online + offline modes); the $party column comes from the
|
||||
// server-injected row content (see ServeInjectedRow). All three just
|
||||
// advertise the synthetic table.yaml/form.yaml entries here so the
|
||||
// tables tool's walkServer finds the spec without a 404 (spec bytes
|
||||
// come from main.go's IsDefaultSpec fallback).
|
||||
if segsURL := strings.Split(strings.Trim(baseURL, "/"), "/"); len(segsURL) == 2 && zddc.IsRowSlot(segsURL[1]) {
|
||||
// Advertise the tables specs (skip any already present on disk).
|
||||
have := make(map[string]bool, len(result))
|
||||
for _, fi := range result {
|
||||
have[fi.Name] = true
|
||||
}
|
||||
appendVirtualRow := func(syntheticName, partyAbs string) {
|
||||
rowURL := baseURL + url.PathEscape(syntheticName)
|
||||
chain := chainFor(partyAbs)
|
||||
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, rowURL)
|
||||
for _, spec := range []string{"table.yaml", "form.yaml"} {
|
||||
if have[spec] {
|
||||
continue
|
||||
}
|
||||
specURL := baseURL + spec
|
||||
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, specURL)
|
||||
if !verbs.Has(zddc.VerbR) {
|
||||
return
|
||||
continue
|
||||
}
|
||||
result = append(result, listing.FileInfo{
|
||||
Name: syntheticName,
|
||||
URL: rowURL,
|
||||
IsDir: false,
|
||||
Virtual: true,
|
||||
Writable: verbs.Has(zddc.VerbW),
|
||||
Verbs: verbs.String(),
|
||||
})
|
||||
}
|
||||
appendVirtualPartyDir := func(party, partyAbs string) {
|
||||
dirURL := baseURL + url.PathEscape(party) + "/"
|
||||
chain := chainFor(partyAbs)
|
||||
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, dirURL)
|
||||
if !verbs.Has(zddc.VerbR) {
|
||||
return
|
||||
}
|
||||
result = append(result, listing.FileInfo{
|
||||
Name: party + "/",
|
||||
URL: dirURL,
|
||||
IsDir: true,
|
||||
Name: spec,
|
||||
URL: specURL,
|
||||
IsDir: false,
|
||||
Virtual: true,
|
||||
Verbs: verbs.String(),
|
||||
})
|
||||
}
|
||||
|
||||
switch vv.Slot {
|
||||
case "ssr":
|
||||
parties, _ := zddc.ListSSRParties(fsRoot, vv.ProjectAbs)
|
||||
for _, party := range parties {
|
||||
partyAbs := filepath.Join(vv.ProjectAbs, "archive", party)
|
||||
appendVirtualRow(party+".yaml", partyAbs)
|
||||
}
|
||||
case "mdl", "rsk":
|
||||
rows, _ := zddc.ListRollupRows(fsRoot, vv.ProjectAbs, vv.Slot)
|
||||
for _, row := range rows {
|
||||
partyAbs := filepath.Join(vv.ProjectAbs, "archive", row.Party)
|
||||
appendVirtualRow(row.SyntheticName, partyAbs)
|
||||
}
|
||||
case "working", "staging", "reviewing":
|
||||
parties, _ := zddc.ListPartyDirsInSlot(fsRoot, vv.ProjectAbs, vv.Slot)
|
||||
for _, party := range parties {
|
||||
partyAbs := filepath.Join(vv.ProjectAbs, "archive", party)
|
||||
appendVirtualPartyDir(party, partyAbs)
|
||||
}
|
||||
}
|
||||
|
||||
// Row rollups carry synthetic spec entries so the tables tool
|
||||
// can walkServer them. Folder-nav virtuals don't need spec
|
||||
// files — they're just party listings rendered by browse.
|
||||
// Verbs reflect actual cascade authority at each synthetic
|
||||
// spec's URL so elevated admins see them as writable (they
|
||||
// CAN materialise an override .zddc / spec by PUTting to
|
||||
// the virtual path). Non-admins fall through to the default
|
||||
// 'r' that the embedded baseline grants on the rollup view.
|
||||
if zddc.IsRowSlot(vv.Slot) {
|
||||
for _, spec := range []string{"table.yaml", "form.yaml"} {
|
||||
specURL := baseURL + spec
|
||||
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, specURL)
|
||||
if !verbs.Has(zddc.VerbR) {
|
||||
continue
|
||||
}
|
||||
result = append(result, listing.FileInfo{
|
||||
Name: spec,
|
||||
URL: specURL,
|
||||
IsDir: false,
|
||||
Virtual: true,
|
||||
Verbs: verbs.String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow folder: append a virtual `received/` entry whose backing
|
||||
|
|
@ -458,67 +380,6 @@ func virtualCanonicalFolders(ctx context.Context, decider policy.Decider, fsRoot
|
|||
return synth
|
||||
}
|
||||
|
||||
// virtualUserHomeEntry returns the synthetic <viewer-email>/ entry that
|
||||
// should be appended to a per-party working/ listing, or (zero, false)
|
||||
// when no synthetic entry applies.
|
||||
//
|
||||
// Under the canonical layout, per-user homes live at
|
||||
// <project>/archive/<party>/working/<email>/ (depth-4 working slot
|
||||
// inside the party folder). The synthetic entry fires when dirPath
|
||||
// case-folds to <project>/archive/<party>/working and the viewer has
|
||||
// no real home folder yet.
|
||||
//
|
||||
// Conditions for the entry to fire:
|
||||
// - dirPath case-folds to <project>/archive/<party>/working at
|
||||
// depth-4 of fsRoot
|
||||
// - viewerEmail is non-empty
|
||||
// - real does not already contain a directory entry that case-folds
|
||||
// to viewerEmail (so a materialised home doesn't get duplicated)
|
||||
func virtualUserHomeEntry(ctx context.Context, decider policy.Decider, fsRoot, dirPath string, principal zddc.Principal, baseURL string, real []listing.FileInfo) (listing.FileInfo, bool) {
|
||||
if principal.Email == "" {
|
||||
return listing.FileInfo{}, false
|
||||
}
|
||||
rel := strings.Trim(filepath.ToSlash(dirPath), "/")
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) != 4 ||
|
||||
!strings.EqualFold(parts[1], "archive") ||
|
||||
!strings.EqualFold(parts[3], "working") {
|
||||
return listing.FileInfo{}, false
|
||||
}
|
||||
for _, fi := range real {
|
||||
if !fi.IsDir {
|
||||
continue
|
||||
}
|
||||
// fi.Name carries a trailing slash for dirs.
|
||||
bare := strings.TrimSuffix(fi.Name, "/")
|
||||
if strings.EqualFold(bare, principal.Email) {
|
||||
return listing.FileInfo{}, false
|
||||
}
|
||||
}
|
||||
// Compute verbs against the would-be home's own chain — the
|
||||
// auto_own_fenced declaration in defaults.zddc.yaml means a real
|
||||
// home grants the creator rwcda; the synthetic entry reports the
|
||||
// same so client-side gating renders the "+ New" affordances
|
||||
// immediately, before the first write materialises the folder.
|
||||
homeAbs := filepath.Join(fsRoot, filepath.FromSlash(dirPath), principal.Email)
|
||||
chain, err := zddc.EffectivePolicy(fsRoot, homeAbs)
|
||||
if err != nil {
|
||||
return listing.FileInfo{}, false
|
||||
}
|
||||
homeURL := baseURL + url.PathEscape(principal.Email) + "/"
|
||||
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, homeURL)
|
||||
if !verbs.Has(zddc.VerbR) {
|
||||
return listing.FileInfo{}, false
|
||||
}
|
||||
return listing.FileInfo{
|
||||
Name: principal.Email + "/",
|
||||
URL: homeURL,
|
||||
IsDir: true,
|
||||
Virtual: true,
|
||||
Verbs: verbs.String(),
|
||||
}, true
|
||||
}
|
||||
|
||||
// readDisplayMap parses dirAbs/.zddc and returns its Display map (or
|
||||
// nil when the file doesn't exist or has no display block). All keys
|
||||
// are case-folded to lowercase so lookupDisplay's case-insensitive
|
||||
|
|
|
|||
|
|
@ -21,157 +21,13 @@ func setupTreeRoot(t *testing.T) string {
|
|||
return root
|
||||
}
|
||||
|
||||
// Per-user homes now live at archive/<party>/working/<email>/ (depth-
|
||||
// 4). The virtual entry fires when listing that path for a viewer
|
||||
// whose home doesn't yet exist on disk.
|
||||
|
||||
func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
|
||||
// Listing a cascade-declared peer that doesn't exist on disk yet returns
|
||||
// an empty slice instead of os.ErrNotExist, so fresh projects don't 404.
|
||||
// (The tables peers mdl/rsk/ssr instead surface their synthetic spec
|
||||
// entries; see TestListDirectory_MdlAggregate.)
|
||||
func TestListDirectory_DeclaredPeerEmptyWhenMissing(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/archive/Acme/working", "alice@example.com",
|
||||
"/Proj/archive/Acme/working/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
||||
var virtual *string
|
||||
for i := range got {
|
||||
if got[i].Virtual {
|
||||
n := got[i].Name
|
||||
virtual = &n
|
||||
}
|
||||
}
|
||||
if virtual == nil {
|
||||
t.Fatalf("expected synthetic <viewer-email>/ entry, got entries: %+v", got)
|
||||
}
|
||||
if *virtual != "alice@example.com/" {
|
||||
t.Errorf("synthetic name = %q, want alice@example.com/", *virtual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
// A real folder exists for the viewer (any case).
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working", "Alice@Example.com"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/archive/Acme/working", "alice@example.com",
|
||||
"/Proj/archive/Acme/working/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, fi := range got {
|
||||
if fi.Virtual {
|
||||
t.Errorf("synthetic entry should be suppressed when a case-fold match exists; got %+v", fi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/archive/Acme/working", "" /* no viewer */,
|
||||
"/Proj/archive/Acme/working/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, fi := range got {
|
||||
if fi.Virtual {
|
||||
t.Errorf("anonymous viewer should not see synthetic entries; got %+v", fi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "staging"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/archive/Acme/staging", "alice@example.com",
|
||||
"/Proj/archive/Acme/staging/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, fi := range got {
|
||||
if fi.Virtual {
|
||||
t.Errorf("staging/ should not have a synthetic user-home entry; got %+v", fi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
// Listing inside working/<email>/ — no synthetic entry should fire.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/archive/Acme/working/alice@example.com", "alice@example.com",
|
||||
"/Proj/archive/Acme/working/alice@example.com/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, fi := range got {
|
||||
if fi.Virtual {
|
||||
t.Errorf("nested working/ subdir must not synthesise the user home; got %+v", fi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
// Pre-existing PascalCase Working/ under archive/<party>/.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "Working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/archive/Acme/Working", "alice@example.com",
|
||||
"/Proj/archive/Acme/Working/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, fi := range got {
|
||||
if fi.Virtual {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("PascalCase Working/ should still surface the synthetic entry; got entries: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Listing a canonical-folder path that doesn't exist on disk yet
|
||||
// returns an empty slice instead of os.ErrNotExist. The stage-strip
|
||||
// nav links into <project>/archive/ etc. unconditionally; this keeps
|
||||
// fresh projects from 404'ing.
|
||||
//
|
||||
// The synthetic per-user home entry fires for the in-party working
|
||||
// slot; other canonical slots return a plain empty listing.
|
||||
func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
// Proj exists; the party folder skeleton does not.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"),
|
||||
|
|
@ -180,25 +36,16 @@ func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
|
|||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
for _, stage := range []string{"working", "staging", "reviewing", "incoming"} {
|
||||
dirPath := "Proj/archive/Acme/" + stage
|
||||
baseURL := "/" + dirPath + "/"
|
||||
for _, peer := range []string{"working", "staging", "reviewing", "incoming"} {
|
||||
dirPath := "Proj/" + peer
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
dirPath, "alice@example.com", baseURL, false, false)
|
||||
dirPath, "alice@example.com", "/"+dirPath+"/", false, false)
|
||||
if err != nil {
|
||||
t.Errorf("ListDirectory(%s) on missing dir: err = %v, want nil", dirPath, err)
|
||||
t.Errorf("ListDirectory(%s) on missing peer: err = %v, want nil", dirPath, err)
|
||||
continue
|
||||
}
|
||||
// working/ surfaces a synthetic <viewer-email>/ entry; the
|
||||
// others should be a flat empty listing.
|
||||
if stage == "working" {
|
||||
if len(got) != 1 || !got[0].Virtual {
|
||||
t.Errorf("ListDirectory(%s) on missing dir: got %+v, want only the virtual home entry", dirPath, got)
|
||||
}
|
||||
} else {
|
||||
if len(got) != 0 {
|
||||
t.Errorf("ListDirectory(%s) on missing dir: got %+v, want empty", dirPath, got)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("ListDirectory(%s) on missing peer: got %+v, want empty", dirPath, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -224,37 +71,37 @@ func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Project-level folder-nav virtual lists only the parties that have
|
||||
// non-empty content in the slot. Empty/missing party slots are
|
||||
// filtered out.
|
||||
func TestListDirectory_VirtualFolderNav_FiltersInFlight(t *testing.T) {
|
||||
// The mdl/ peer root lists its party subdirs (folder-nav) plus the
|
||||
// synthetic table.yaml/form.yaml spec entries. The tables tool builds
|
||||
// the cross-party aggregate by recursing into the party subdirs
|
||||
// client-side; the server just lists normally + advertises the spec.
|
||||
func TestListDirectory_MdlAggregate(t *testing.T) {
|
||||
root := setupTreeRoot(t)
|
||||
// Acme has a populated working/; Beta is scaffolded but empty.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Proj", "archive", "Acme", "working", "draft.md"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Beta", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
mk := func(p string) {
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("id: x\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mk(filepath.Join(root, "Proj", "mdl", "Acme", "D-001.yaml"))
|
||||
mk(filepath.Join(root, "Proj", "mdl", "Beta", "D-009.yaml"))
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/working", "alice@example.com", "/Proj/working/", false, false)
|
||||
"Proj/mdl", "alice@example.com", "/Proj/mdl/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
var partyDirs []string
|
||||
names := map[string]bool{}
|
||||
for _, fi := range got {
|
||||
if fi.IsDir && fi.Virtual {
|
||||
partyDirs = append(partyDirs, fi.Name)
|
||||
}
|
||||
names[fi.Name] = true
|
||||
}
|
||||
want := []string{"Acme/"}
|
||||
if len(partyDirs) != 1 || partyDirs[0] != want[0] {
|
||||
t.Errorf("project-level folder-nav listing = %v, want %v", partyDirs, want)
|
||||
for _, want := range []string{"Acme/", "Beta/", "table.yaml", "form.yaml"} {
|
||||
if !names[want] {
|
||||
t.Errorf("mdl/ listing missing %q; got %+v", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,9 +148,6 @@ func TestListDirectory_VerbsPerEntry(t *testing.T) {
|
|||
if fi.Verbs != want {
|
||||
t.Errorf("entry %s verbs = %q, want %q", fi.Name, fi.Verbs, want)
|
||||
}
|
||||
// Writable stays in lockstep with verbs for the transition
|
||||
// window — w bit for files, r/c semantics for dirs (no
|
||||
// Writable on dirs today; we don't assert it).
|
||||
if !fi.IsDir {
|
||||
wantWritable := want == "rw"
|
||||
if fi.Writable != wantWritable {
|
||||
|
|
@ -330,7 +174,6 @@ func TestListDirectory_VerbsActiveAdminBypass(t *testing.T) {
|
|||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
// Elevated admin sees rwcda everywhere.
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj", "admin@example.com", "/Proj/", false, true /* elevated */)
|
||||
if err != nil {
|
||||
|
|
@ -342,8 +185,6 @@ func TestListDirectory_VerbsActiveAdminBypass(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Same admin un-elevated sees nothing (no explicit ACL grant,
|
||||
// admin bypass disabled).
|
||||
got, err = ListDirectory(context.Background(), nil, root,
|
||||
"Proj", "admin@example.com", "/Proj/", false, false)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ import (
|
|||
|
||||
const opAcceptTransmittal = "accept-transmittal"
|
||||
|
||||
// incomingURLPattern matches /<project>/archive/<party>/incoming/<transmittal>/.
|
||||
var incomingURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/incoming/([^/]+)/?$`)
|
||||
// incomingURLPattern matches /<project>/incoming/<party>/<transmittal>/.
|
||||
var incomingURLPattern = regexp.MustCompile(`^/([^/]+)/incoming/([^/]+)/([^/]+)/?$`)
|
||||
|
||||
type acceptRequest struct {
|
||||
ReceivedDate string `yaml:"received_date"`
|
||||
|
|
@ -84,11 +84,17 @@ func serveAcceptTransmittal(cfg config.Config, w http.ResponseWriter, r *http.Re
|
|||
cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/"
|
||||
m := incomingURLPattern.FindStringSubmatch(cleanURL)
|
||||
if m == nil {
|
||||
http.Error(w, "Bad Request — accept-transmittal must POST to /<project>/archive/<party>/incoming/<transmittal>/", http.StatusBadRequest)
|
||||
http.Error(w, "Bad Request — accept-transmittal must POST to /<project>/incoming/<party>/<transmittal>/", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
project, party, transmittalFolder := m[1], m[2], m[3]
|
||||
|
||||
// Filing requires the party to be registered (ssr/<party>.yaml).
|
||||
if !zddc.PartyRegistered(filepath.Join(cfg.Root, project), "ssr", party) {
|
||||
http.Error(w, "Conflict — unknown party \""+party+"\"; register it in ssr/ first", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
date, tracking, _, _, ok := zddc.ParseTransmittalFolder(transmittalFolder)
|
||||
if !ok {
|
||||
http.Error(w, "Bad Request — folder name does not conform to ZDDC transmittal grammar (expected YYYY-MM-DD_<tracking> (<status>) - <title>)", http.StatusBadRequest)
|
||||
|
|
@ -115,7 +121,7 @@ func serveAcceptTransmittal(cfg config.Config, w http.ResponseWriter, r *http.Re
|
|||
}
|
||||
}
|
||||
|
||||
incomingAbs := filepath.Join(cfg.Root, project, "archive", party, "incoming", transmittalFolder)
|
||||
incomingAbs := filepath.Join(cfg.Root, project, "incoming", party, transmittalFolder)
|
||||
receivedAbs := filepath.Join(cfg.Root, project, "archive", party, "received", tracking)
|
||||
receivedURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"
|
||||
|
||||
|
|
|
|||
|
|
@ -25,13 +25,15 @@ func acceptSetup(t *testing.T) (config.Config, func(target, email string, body [
|
|||
mustWriteHelper(t, filepath.Join(root, ".zddc"),
|
||||
"admins:\n - alice@example.com\n"+
|
||||
"roles:\n document_controller:\n members: [alice@example.com]\n")
|
||||
for _, d := range []string{"Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation"} {
|
||||
for _, d := range []string{"Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", d, err)
|
||||
}
|
||||
}
|
||||
// Register the party (party_source: ssr) so filing isn't 409'd.
|
||||
mustWriteHelper(t, filepath.Join(root, "Project-1/ssr/Acme.yaml"), "kind: SSR\n")
|
||||
// Seed two conforming files inside the transmittal folder.
|
||||
transmittalDir := filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation")
|
||||
transmittalDir := filepath.Join(root, "Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation")
|
||||
mustWriteHelper(t, filepath.Join(transmittalDir, "Acme-0042_A (RFI) - Foundation.pdf"), "%PDF-")
|
||||
mustWriteHelper(t, filepath.Join(transmittalDir, "Acme-0042_A (RFI) - Cover Letter.pdf"), "%PDF-")
|
||||
zddc.InvalidateCache(root)
|
||||
|
|
@ -64,7 +66,7 @@ func acceptSetup(t *testing.T) (config.Config, func(target, email string, body [
|
|||
// from incoming/ to received/, renamed to tracking-only.
|
||||
func TestAccept_FreshAcceptance(t *testing.T) {
|
||||
_, do, root := acceptSetup(t)
|
||||
target := "/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/"
|
||||
target := "/Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation/"
|
||||
rec := do(target, "alice@example.com", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
|
|
@ -87,7 +89,7 @@ func TestAccept_FreshAcceptance(t *testing.T) {
|
|||
t.Errorf("primary file not moved into received/: %v", err)
|
||||
}
|
||||
// Source should no longer exist.
|
||||
if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(filepath.Join(root, "Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation")); !os.IsNotExist(err) {
|
||||
t.Errorf("source folder still present after rename")
|
||||
}
|
||||
}
|
||||
|
|
@ -98,8 +100,8 @@ func TestAccept_FreshAcceptance(t *testing.T) {
|
|||
func TestAccept_NonConformingFilename(t *testing.T) {
|
||||
_, do, root := acceptSetup(t)
|
||||
// Drop a bad file alongside the good ones.
|
||||
mustWriteHelper(t, filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/random-notes.txt"), "oops")
|
||||
rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil)
|
||||
mustWriteHelper(t, filepath.Join(root, "Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation/random-notes.txt"), "oops")
|
||||
rec := do("/Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status=%d, want 409; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
|
@ -107,7 +109,7 @@ func TestAccept_NonConformingFilename(t *testing.T) {
|
|||
t.Errorf("error body should name the violating file; got %s", rec.Body.String())
|
||||
}
|
||||
// Source untouched.
|
||||
if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation")); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(root, "Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation")); err != nil {
|
||||
t.Errorf("source folder removed despite rejection: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -117,11 +119,11 @@ func TestAccept_NonConformingFilename(t *testing.T) {
|
|||
// outer shape but the folder grammar fails).
|
||||
func TestAccept_NonConformingFolderName(t *testing.T) {
|
||||
_, do, root := acceptSetup(t)
|
||||
badDir := filepath.Join(root, "Project-1/archive/Acme/incoming/bad-folder-name")
|
||||
badDir := filepath.Join(root, "Project-1/incoming/Acme/bad-folder-name")
|
||||
if err := os.MkdirAll(badDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := do("/Project-1/archive/Acme/incoming/bad-folder-name/", "alice@example.com", nil)
|
||||
rec := do("/Project-1/incoming/Acme/bad-folder-name/", "alice@example.com", nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
|
@ -139,7 +141,7 @@ func TestAccept_PlanReviewChain(t *testing.T) {
|
|||
"plan_response_date: 2026-06-15",
|
||||
"",
|
||||
}, "\n"))
|
||||
rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", body)
|
||||
rec := do("/Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
|
@ -164,18 +166,18 @@ func TestAccept_PlanReviewChain(t *testing.T) {
|
|||
// folder. Re-using a filename is rejected by WORM.
|
||||
func TestAccept_Merge(t *testing.T) {
|
||||
_, do, root := acceptSetup(t)
|
||||
rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil)
|
||||
rec := do("/Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("first accept status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Build a second transmittal folder with the same tracking but a
|
||||
// distinct rev so the filenames don't collide.
|
||||
secondDir := filepath.Join(root, "Project-1/archive/Acme/incoming/2026-06-01_Acme-0042 (RFI) - Followup")
|
||||
secondDir := filepath.Join(root, "Project-1/incoming/Acme/2026-06-01_Acme-0042 (RFI) - Followup")
|
||||
if err := os.MkdirAll(secondDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteHelper(t, filepath.Join(secondDir, "Acme-0042_B (RFI) - Foundation.pdf"), "%PDF-")
|
||||
rec = do("/Project-1/archive/Acme/incoming/2026-06-01_Acme-0042 (RFI) - Followup/", "alice@example.com", nil)
|
||||
rec = do("/Project-1/incoming/Acme/2026-06-01_Acme-0042 (RFI) - Followup/", "alice@example.com", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("second accept status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,12 +28,12 @@ import (
|
|||
//
|
||||
// - admin@example.com — root super-admin
|
||||
// - alice@example.com — subtree admin of Project-1/archive/Acme/working
|
||||
// (via per-dir .zddc admins:) — used to test
|
||||
// subtree scope
|
||||
// (via per-dir .zddc admins:) — used to test
|
||||
// subtree scope
|
||||
// - bob@example.com — document_controller role member (gets WORM cr
|
||||
// on received/ + issued/ via cascade defaults)
|
||||
// on received/ + issued/ via cascade defaults)
|
||||
// - eve@example.com — non-admin, project_team only (read-only across
|
||||
// the project per defaults)
|
||||
// the project per defaults)
|
||||
//
|
||||
// Plus one file each in working/, issued/, received/ so we can exercise
|
||||
// reads + writes across the cascade.
|
||||
|
|
@ -57,6 +57,11 @@ func invariantsFixture(t *testing.T) (config.Config, string) {
|
|||
}
|
||||
}
|
||||
|
||||
// Register the party (party_source: ssr) so writes under the
|
||||
// party_source peers aren't rejected before the WORM/admin checks
|
||||
// these invariants actually exercise.
|
||||
mustWriteHelper(t, filepath.Join(root, "Project-1/ssr/Acme.yaml"), "kind: SSR\n")
|
||||
|
||||
// Subtree-admin grant: alice administers Project-1/archive/Acme/working/.
|
||||
mustWriteHelper(t,
|
||||
filepath.Join(root, "Project-1/archive/Acme/working/.zddc"),
|
||||
|
|
@ -294,12 +299,12 @@ func TestInvariant_ForwardAuthEndpointGatesOnAdminsList(t *testing.T) {
|
|||
// Targets:
|
||||
// - /.zddc — root file (root admins: govern)
|
||||
// - /Project-1/.zddc — project file (no on-disk .zddc;
|
||||
// write must materialise it; root
|
||||
// admins still govern via cascade)
|
||||
// write must materialise it; root
|
||||
// admins still govern via cascade)
|
||||
// - /Project-1/archive/Acme/working/.zddc — subtree file; alice administers
|
||||
// this subtree via its own admins:
|
||||
// list (so alice's write doesn't
|
||||
// require root-admin authority).
|
||||
// this subtree via its own admins:
|
||||
// list (so alice's write doesn't
|
||||
// require root-admin authority).
|
||||
//
|
||||
// Expected status: 200 or 201 on success; 403 on denial; 404 only when
|
||||
// resolveTargetPath rejects the path (e.g. empty email gets 403 from
|
||||
|
|
|
|||
|
|
@ -299,23 +299,20 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, "PUT must target a file, not a directory", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Virtual project-level table views — SSR / MDL rollup / RSK
|
||||
// rollup. The PUT URL lives in <project>/{ssr,mdl,rsk}/...; the
|
||||
// underlying bytes belong inside <project>/archive/<party>/. We
|
||||
// rewrite abs + cleanURL to the canonical path so the rest of
|
||||
// this function (ACL gate, ETag, audit, conversion-cache purge)
|
||||
// operates on the real file location.
|
||||
//
|
||||
// SSR row PUTs land at archive/<party>/ssr.yaml; MDL/RSK rollup
|
||||
// row PUTs land at archive/<party>/<slot>/<file>.yaml. Same
|
||||
// shape as the virtual-received rewrite below.
|
||||
if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() {
|
||||
abs = vv.CanonicalAbs
|
||||
cleanURL = vv.CanonicalURL
|
||||
w.Header().Set("X-ZDDC-Resolved-Path", cleanURL)
|
||||
// A PUT that would introduce a new party folder under a party_source
|
||||
// peer (e.g. working/<newparty>/file, or filing into
|
||||
// archive/<newparty>/received/) requires the party to be registered.
|
||||
if rejected, why, _ := partySourceGate(cfg.Root, abs); rejected {
|
||||
http.Error(w, why, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Register rows (ssr/<party>.yaml, mdl|rsk/<party>/<file>.yaml) are
|
||||
// real files in the flat-peer layout — a PUT targets them directly,
|
||||
// no virtual→canonical rewrite. The path-derived $party/name column
|
||||
// is injected only on read (ServeInjectedRow) and stripped by the
|
||||
// client before submit.
|
||||
|
||||
// Virtual received/ rewrite. When the PUT targets a file under the
|
||||
// synthetic <workflow>/received/<file> URL, the canonical record is
|
||||
// WORM — we can't write there. Convention: treat the drop as a
|
||||
|
|
@ -510,20 +507,10 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
// Virtual project-level table views. SSR row deletes are refused
|
||||
// (would orphan the party folder and its mdl/rsk contents) — use
|
||||
// the archive view to delete a party. MDL/RSK rollup row deletes
|
||||
// pass through to the canonical archive/<party>/<slot>/<file>.yaml
|
||||
// path with the normal ACL gate.
|
||||
if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() {
|
||||
if vv.Kind == zddc.VirtualViewSSRRow {
|
||||
http.Error(w, "Method Not Allowed — delete the party folder via the archive view, not the SSR table", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
abs = vv.CanonicalAbs
|
||||
cleanURL = vv.CanonicalURL
|
||||
w.Header().Set("X-ZDDC-Resolved-Path", cleanURL)
|
||||
}
|
||||
// Register rows are real files — a DELETE targets them directly with
|
||||
// the normal ACL gate. (Deleting an ssr/<party>.yaml de-registers the
|
||||
// party; the ssr/ ACL grants delete only to admins by default so a
|
||||
// party with archived records can't be orphaned.)
|
||||
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
|
|
@ -612,6 +599,12 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, "MOVE destination must be a file path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// A move whose destination introduces a new party folder under a
|
||||
// party_source peer requires the party to be registered.
|
||||
if rejected, why, _ := partySourceGate(cfg.Root, dstAbs); rejected {
|
||||
http.Error(w, "destination: "+why, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve canonical-folder casing on src + dst (no side effects).
|
||||
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, srcAbs); err == nil {
|
||||
|
|
@ -725,19 +718,14 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
// Project-root mkdir policy: the only physical child allowed
|
||||
// directly under <project>/ is `archive` (plus _/.-prefixed
|
||||
// system names). Mkdir of any other name — including the six
|
||||
// virtual aggregator names (ssr/mdl/rsk/working/staging/reviewing)
|
||||
// — is rejected with 409, because the virtual would shadow any
|
||||
// physical folder created at the same URL.
|
||||
// non-peer name — is rejected with 409.
|
||||
if rejected, why := rejectProjectRootMkdir(cfg.Root, abs); rejected {
|
||||
http.Error(w, why, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
// Mkdir INSIDE a virtual aggregator (working/staging/reviewing/…) has
|
||||
// no physical home — the content is party-scoped. 409 with a pointer
|
||||
// at archive/<party>/<slot>/ rather than silently creating an
|
||||
// unreachable shadow folder. (Browse's picker targets the archive/
|
||||
// path directly, so this is the bypass fallback.)
|
||||
if rejected, why := rejectProjectAggregatorMkdir(cfg.Root, abs); rejected {
|
||||
// A new party folder under a party_source peer requires the party to
|
||||
// be registered (ssr/<party>.yaml exists); else 409.
|
||||
if rejected, why := rejectUnregisteredPartyMkdir(cfg.Root, abs); rejected {
|
||||
http.Error(w, why, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
|
@ -852,48 +840,54 @@ func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) {
|
|||
return false, ""
|
||||
}
|
||||
name := parts[1]
|
||||
if name == "archive" {
|
||||
return false, ""
|
||||
}
|
||||
if strings.HasPrefix(name, "_") || strings.HasPrefix(name, ".") {
|
||||
// System-reserved namespace; allowed.
|
||||
return false, ""
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
if zddc.IsVirtualAggregatorSlot(lower) {
|
||||
return true, "Conflict — " + lower + "/ is a project-level virtual aggregator and cannot be created as a physical folder. Files of this kind live under archive/<party>/" + lower + "/."
|
||||
if zddc.IsProjectPeer(name) {
|
||||
return false, ""
|
||||
}
|
||||
return true, "Conflict — only archive/ and system-reserved (_/. prefix) folders may be created directly under a project. Files belong inside archive/<party>/..."
|
||||
return true, "Conflict — only the canonical peers (archive, incoming, working, staging, reviewing, mdl, rsk, ssr) and system-reserved (_/. prefix) folders may be created directly under a project."
|
||||
}
|
||||
|
||||
// rejectProjectAggregatorMkdir reports whether a mkdir lands INSIDE a
|
||||
// project-level virtual aggregator — <project>/{ssr,mdl,rsk,working,
|
||||
// staging,reviewing}/<name>[/...] (depth 3+). Those slots aggregate
|
||||
// per-party content; a folder created there has no physical home. The
|
||||
// real location is archive/<party>/<slot>/<name>, so we 409 with a
|
||||
// message pointing at the party-scoped path. (rejectProjectRootMkdir
|
||||
// handles the depth-2 case — the aggregator name itself.) The browse
|
||||
// "New folder" picker resolves the party client-side and targets the
|
||||
// archive/ path directly, so a user never trips this in normal use; it
|
||||
// is the clean fallback for a direct/scripted mkdir that bypassed it.
|
||||
func rejectProjectAggregatorMkdir(fsRoot, abs string) (bool, string) {
|
||||
// rejectUnregisteredPartyMkdir enforces the party_source cascade key: a
|
||||
// new <party> folder under a peer that declares party_source (every peer
|
||||
// except ssr/) may be created only if the party is registered — i.e. the
|
||||
// registry entry exists (ssr/<party>.yaml). Applies at the party-segment
|
||||
// depth and below (<project>/<peer>/<party>[/...]). Registration itself
|
||||
// (creating ssr/<party>.yaml) is not gated — ssr/ sets no party_source.
|
||||
func rejectUnregisteredPartyMkdir(fsRoot, abs string) (bool, string) {
|
||||
reject, msg, _ := partySourceGate(fsRoot, abs)
|
||||
return reject, msg
|
||||
}
|
||||
|
||||
// partySourceGate is the shared party_source check used by mkdir, PUT
|
||||
// (create), and move (dst). It returns reject=true (+ a 409 message)
|
||||
// when abs would introduce a <party> segment under a party_source peer
|
||||
// for a party that isn't registered. The third return is the resolved
|
||||
// party name (for callers that want to log it).
|
||||
func partySourceGate(fsRoot, abs string) (reject bool, msg, party string) {
|
||||
rel, err := filepath.Rel(fsRoot, abs)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
return false, "", ""
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == "." || strings.HasPrefix(rel, "../") {
|
||||
return false, ""
|
||||
return false, "", ""
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) < 3 {
|
||||
return false, "" // depth-2 (the slot itself) is rejectProjectRootMkdir's job
|
||||
return false, "", "" // <project>/<peer> — no party segment yet
|
||||
}
|
||||
if slot := strings.ToLower(parts[1]); zddc.IsVirtualAggregatorSlot(slot) {
|
||||
return true, "Conflict — " + slot + "/ is a project-level virtual aggregator; folders here belong to a party. " +
|
||||
"Create it under archive/<party>/" + slot + "/ — browse's \"New folder\" picker prompts you for the party."
|
||||
project, peer, p := parts[0], parts[1], parts[2]
|
||||
source := zddc.PartySourceAt(fsRoot, filepath.Join(fsRoot, project, peer))
|
||||
if source == "" {
|
||||
return false, "", "" // peer does no party gating (e.g. ssr/)
|
||||
}
|
||||
return false, ""
|
||||
if zddc.PartyRegistered(filepath.Join(fsRoot, project), source, p) {
|
||||
return false, "", p
|
||||
}
|
||||
return true, "Conflict — unknown party \"" + p + "\". Register it first by creating " + source + "/" + p + ".yaml (the SSR form).", p
|
||||
}
|
||||
|
||||
// auditFile emits a structured log line for each file API operation.
|
||||
|
|
|
|||
|
|
@ -40,6 +40,15 @@ func fileAPITestSetup(t *testing.T, dirs []string, seed map[string]string) (cfg
|
|||
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", d, err)
|
||||
}
|
||||
// Auto-register any party implied by a pre-created peer path
|
||||
// (<project>/<peer>/<party>/… or <project>/archive/<party>/…) so
|
||||
// the party_source gate doesn't 409 before the test's real
|
||||
// assertion. party_source: ssr → registry entry ssr/<party>.yaml.
|
||||
if segs := strings.Split(filepath.ToSlash(d), "/"); len(segs) >= 3 {
|
||||
ssrFile := filepath.Join(root, segs[0], "ssr", segs[2]+".yaml")
|
||||
_ = os.MkdirAll(filepath.Dir(ssrFile), 0o755)
|
||||
_ = os.WriteFile(ssrFile, []byte("kind: SSR\n"), 0o644)
|
||||
}
|
||||
}
|
||||
for rel, body := range seed {
|
||||
full := filepath.Join(root, rel)
|
||||
|
|
@ -357,15 +366,15 @@ func TestFileAPI_MkdirCreates(t *testing.T) {
|
|||
// Project-root mkdir is restricted to archive/ + system names
|
||||
// after the layout reshape; test mkdir at a depth where the
|
||||
// guard doesn't fire (under archive/<party>/incoming/).
|
||||
_, do, root := fileAPITestSetup(t, []string{"Proj/archive/Acme/incoming"}, nil)
|
||||
_, do, root := fileAPITestSetup(t, []string{"Proj/incoming/Acme"}, nil)
|
||||
|
||||
rec := do(http.MethodPost, "/Proj/archive/Acme/incoming/newfolder/", "alice@example.com", nil, map[string]string{
|
||||
rec := do(http.MethodPost, "/Proj/incoming/Acme/newfolder/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(root, "Proj/archive/Acme/incoming/newfolder"))
|
||||
info, err := os.Stat(filepath.Join(root, "Proj/incoming/Acme/newfolder"))
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
|
|
@ -375,8 +384,8 @@ func TestFileAPI_MkdirCreates(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestFileAPI_MkdirIdempotent(t *testing.T) {
|
||||
_, do, _ := fileAPITestSetup(t, []string{"Proj/archive/Acme/incoming/exists"}, nil)
|
||||
rec := do(http.MethodPost, "/Proj/archive/Acme/incoming/exists/", "alice@example.com", nil, map[string]string{
|
||||
_, do, _ := fileAPITestSetup(t, []string{"Proj/incoming/Acme/exists"}, nil)
|
||||
rec := do(http.MethodPost, "/Proj/incoming/Acme/exists/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
|
|
@ -385,9 +394,8 @@ func TestFileAPI_MkdirIdempotent(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestFileAPI_MkdirProjectRootGuard — direct mkdir at <project>/<name>/
|
||||
// is restricted: archive/ and system names (_/.-prefix) are allowed,
|
||||
// any other name (including the six virtual aggregator names) is
|
||||
// rejected with 409.
|
||||
// is restricted to the canonical peers + system names (_/.-prefix); any
|
||||
// other name is rejected with 409.
|
||||
func TestFileAPI_MkdirProjectRootGuard(t *testing.T) {
|
||||
_, do, _ := fileAPITestSetup(t, []string{"Proj"}, nil)
|
||||
// Reject ad-hoc name.
|
||||
|
|
@ -397,22 +405,15 @@ func TestFileAPI_MkdirProjectRootGuard(t *testing.T) {
|
|||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("want 409 for /Proj/notes/, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Reject each virtual aggregator name.
|
||||
for _, name := range []string{"ssr", "mdl", "rsk", "working", "staging", "reviewing"} {
|
||||
// Allow each canonical peer name.
|
||||
for _, name := range []string{"archive", "ssr", "mdl", "rsk", "working", "staging", "reviewing", "incoming"} {
|
||||
rec := do(http.MethodPost, "/Proj/"+name+"/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("%s: want 409, got %d: %s", name, rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: want 201/200 (canonical peer), got %d: %s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// Allow archive/.
|
||||
rec = do(http.MethodPost, "/Proj/archive/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("want 201 for /Proj/archive/, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// `_`/`.`-prefixed system names are caught earlier (resolveTargetPath
|
||||
// rejects them as reserved path segments with 404 — see fileapi.go
|
||||
// resolveTargetPath); the mkdir guard would also allow them, so the
|
||||
|
|
@ -477,7 +478,7 @@ func TestFileAPI_AnonymousDenied(t *testing.T) {
|
|||
// roles defined at root.
|
||||
//
|
||||
// The project is "Project-X"; the counterparty is "Acme". URLs target
|
||||
// paths like /Project-X/archive/Acme/incoming/<file>.
|
||||
// paths like /Project-X/incoming/Acme/<file>.
|
||||
//
|
||||
// Returns the same do() helper as fileAPITestSetup.
|
||||
func rolePermissionsTestSetup(t *testing.T) (cfg config.Config, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder, root string) {
|
||||
|
|
@ -503,21 +504,28 @@ acl:
|
|||
t.Fatalf("root .zddc: %v", err)
|
||||
}
|
||||
|
||||
// Project + per-party canonical layout.
|
||||
partyDir := filepath.Join(root, "Project-X", "archive", "Acme")
|
||||
for _, sub := range []string{"incoming", "issued", "received"} {
|
||||
if err := os.MkdirAll(filepath.Join(partyDir, sub), 0o755); err != nil {
|
||||
t.Fatalf("mkdir party/%s: %v", sub, err)
|
||||
}
|
||||
// Register the party (party_source: ssr) — its existence gates the peers.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Project-X", "ssr"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir ssr: %v", err)
|
||||
}
|
||||
partyZ := []byte(`acl:
|
||||
permissions:
|
||||
vendor_acme: rwcd
|
||||
_doc_controller: rwcda
|
||||
_company: ""
|
||||
`)
|
||||
if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), partyZ, 0o644); err != nil {
|
||||
t.Fatalf("party .zddc: %v", err)
|
||||
if err := os.WriteFile(filepath.Join(root, "Project-X", "ssr", "Acme.yaml"), []byte("kind: SSR\n"), 0o644); err != nil {
|
||||
t.Fatalf("register party: %v", err)
|
||||
}
|
||||
// The counterparty's inbound drop zone is a top-level peer now; grant
|
||||
// the vendor rwcd there (the DC would set this on incoming/<party>/.zddc).
|
||||
incomingDir := filepath.Join(root, "Project-X", "incoming", "Acme")
|
||||
if err := os.MkdirAll(incomingDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir incoming/Acme: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(incomingDir, ".zddc"),
|
||||
[]byte("acl:\n permissions:\n vendor_acme: rwcd\n _doc_controller: rwcda\n"), 0o644); err != nil {
|
||||
t.Fatalf("incoming .zddc: %v", err)
|
||||
}
|
||||
// The committed record (WORM) stays under archive/<party>/.
|
||||
for _, sub := range []string{"issued", "received"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, "Project-X", "archive", "Acme", sub), 0o755); err != nil {
|
||||
t.Fatalf("mkdir archive/Acme/%s: %v", sub, err)
|
||||
}
|
||||
}
|
||||
|
||||
zddc.InvalidateCache(root)
|
||||
|
|
@ -554,12 +562,12 @@ func TestFileAPI_RoleBasedVendorIncoming(t *testing.T) {
|
|||
_, do, _ := rolePermissionsTestSetup(t)
|
||||
|
||||
// Vendor PUTs into their incoming → 201.
|
||||
rec := do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data"), nil)
|
||||
rec := do(http.MethodPut, "/Project-X/incoming/Acme/submission.pdf", "rep@acme.com", []byte("data"), nil)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("PUT vendor → incoming: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Vendor overwrites the same file → 200 (rwcd has w).
|
||||
rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data2"), nil)
|
||||
rec = do(http.MethodPut, "/Project-X/incoming/Acme/submission.pdf", "rep@acme.com", []byte("data2"), nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT vendor → incoming overwrite: want 200, got %d", rec.Code)
|
||||
}
|
||||
|
|
@ -658,14 +666,14 @@ func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
|
|||
|
||||
// Vendor creates a folder under their incoming. Server should
|
||||
// auto-write a .zddc granting them rwcda on the new subtree.
|
||||
rec := do(http.MethodPost, "/Project-X/archive/Acme/incoming/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{
|
||||
rec := do(http.MethodPost, "/Project-X/incoming/Acme/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
autoZ := filepath.Join(root, "Project-X/archive/Acme/incoming/2026-05-15-issue/.zddc")
|
||||
autoZ := filepath.Join(root, "Project-X/incoming/Acme/2026-05-15-issue/.zddc")
|
||||
data, err := os.ReadFile(autoZ)
|
||||
if err != nil {
|
||||
t.Fatalf("auto .zddc not written: %v", err)
|
||||
|
|
@ -684,7 +692,7 @@ func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
|
|||
// now PUT a brand-new file inside their owned folder where they
|
||||
// otherwise wouldn't have ACL admin rights.
|
||||
zddc.InvalidateCache(root)
|
||||
rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil)
|
||||
rec = do(http.MethodPut, "/Project-X/incoming/Acme/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("vendor PUT in own subtree: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
|
@ -716,39 +724,37 @@ func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// (The pre-reshape staging↔working mirror was retired: with staging at
|
||||
// archive/<party>/staging/<batch>/ and working at archive/<party>/
|
||||
// working/<email>/, the project-level pairing no longer maps cleanly.
|
||||
// Tests for the removed behaviour have been deleted.)
|
||||
|
||||
// Mkdir INSIDE a project-level virtual aggregator is 409'd with a
|
||||
// pointer at the party-scoped path, instead of silently materialising an
|
||||
// unreachable shadow. The same folder created under archive/<party>/
|
||||
// <slot>/ succeeds — which is what browse's party picker targets.
|
||||
func TestFileAPI_MkdirInAggregatorRejected(t *testing.T) {
|
||||
// party_source gating: creating a party folder under a workspace peer
|
||||
// 409s until the party is registered (ssr/<party>.yaml exists), then
|
||||
// succeeds.
|
||||
func TestFileAPI_PartySourceGate(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} {
|
||||
rec := do(http.MethodPost, "/Proj/"+slot+"/foo/", "alice@example.com", nil, map[string]string{
|
||||
// Unregistered party → 409 under each party_source peer.
|
||||
for _, peer := range []string{"working", "staging", "reviewing", "incoming", "mdl", "rsk"} {
|
||||
rec := do(http.MethodPost, "/Proj/"+peer+"/Acme/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("%s: mkdir in aggregator: want 409, got %d: %s", slot, rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", slot)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s: aggregator slot must not be materialised; got err=%v", slot, err)
|
||||
t.Errorf("%s: mkdir for unregistered party: want 409, got %d: %s", peer, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The party-scoped path the picker resolves to works.
|
||||
rec := do(http.MethodPost, "/Proj/archive/Acme/working/drafts/", "alice@example.com", nil, map[string]string{
|
||||
// Register the party (ssr/ has no party_source — a plain create).
|
||||
rec := do(http.MethodPut, "/Proj/ssr/Acme.yaml", "alice@example.com", []byte("kind: SSR\n"), nil)
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Fatalf("register party via ssr/: want 201/200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Now the workspace folder can be created.
|
||||
rec = do(http.MethodPost, "/Proj/working/Acme/drafts/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("party-scoped mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("registered-party mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "Acme", "working", "drafts")); err != nil {
|
||||
t.Errorf("party-scoped folder not created: %v", err)
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "Acme", "drafts")); err != nil {
|
||||
t.Errorf("workspace folder not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -203,36 +203,11 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
|
|||
|
||||
if strings.HasSuffix(underlying, ".yaml") {
|
||||
// /<dir>/<id>.yaml.html — re-edit / update. Spec lives in the
|
||||
// SAME directory as the row file (<dir>/form.yaml) UNLESS the
|
||||
// URL maps to one of the project-level virtual views, in which
|
||||
// case the canonical SpecPath / DataPath are inside the per-
|
||||
// party archive folder. ResolveVirtualView handles the rewrite;
|
||||
// SubmitURL stays as the virtual URL so the form POSTs back to
|
||||
// the same endpoint (which re-resolves to the same canonical
|
||||
// paths on the second pass).
|
||||
if vv := zddc.ResolveVirtualView(fsRoot, underlying); vv.Resolved && vv.Kind.IsRowKind() {
|
||||
var specPath string
|
||||
switch vv.Kind {
|
||||
case zddc.VirtualViewSSRRow:
|
||||
specPath = vv.SchemaAbs
|
||||
case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow:
|
||||
specPath = filepath.Join(vv.PartyArchive, vv.Slot, "form.yaml")
|
||||
}
|
||||
if !specEligible(specPath) {
|
||||
return nil
|
||||
}
|
||||
kind := "render-edit"
|
||||
if method == http.MethodPost {
|
||||
kind = "update"
|
||||
}
|
||||
return &FormRequest{
|
||||
Kind: kind,
|
||||
SpecPath: specPath,
|
||||
DataPath: vv.CanonicalAbs,
|
||||
SubmitURL: urlPath,
|
||||
}
|
||||
}
|
||||
|
||||
// SAME directory as the row file (<dir>/form.yaml). Register rows
|
||||
// are real files now (ssr/<party>.yaml, mdl|rsk/<party>/<file>.yaml),
|
||||
// so the in-dir rule resolves them directly — the spec falls back
|
||||
// to the embedded default via IsDefaultSpecAbs when no on-disk
|
||||
// form.yaml exists.
|
||||
dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/")))
|
||||
dataAbs := filepath.Join(fsRoot, dataRel)
|
||||
if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot {
|
||||
|
|
|
|||
|
|
@ -173,26 +173,26 @@ func TestRecognizeFormRequest_DefaultMdlAtArchiveParty(t *testing.T) {
|
|||
|
||||
// Empty form / create at archive/<party>/mdl/form.html — no spec
|
||||
// on disk, no mdl/ dir on disk, default-MDL fallback applies.
|
||||
got := RecognizeFormRequest(root, "GET", "/Project/archive/PartyA/mdl/form.html")
|
||||
got := RecognizeFormRequest(root, "GET", "/Project/mdl/PartyA/form.html")
|
||||
if got == nil || got.Kind != "render-empty" {
|
||||
t.Fatalf("GET mdl/form.html: got %+v want render-empty via default-MDL fallback", got)
|
||||
}
|
||||
if got.SpecPath != filepath.Join(root, "Project", "archive", "PartyA", "mdl", "form.yaml") {
|
||||
if got.SpecPath != filepath.Join(root, "Project", "mdl", "PartyA", "form.yaml") {
|
||||
t.Errorf("SpecPath = %q", got.SpecPath)
|
||||
}
|
||||
|
||||
// POST → create.
|
||||
got = RecognizeFormRequest(root, "POST", "/Project/archive/PartyA/mdl/form.html")
|
||||
got = RecognizeFormRequest(root, "POST", "/Project/mdl/PartyA/form.html")
|
||||
if got == nil || got.Kind != "create" {
|
||||
t.Fatalf("POST mdl/form.html: got %+v want create", got)
|
||||
}
|
||||
|
||||
// Re-edit (<id>.yaml.html) at archive/<party>/mdl/ — same default
|
||||
// spec applies. The data file itself must exist on disk; the spec
|
||||
// is the embedded default in the same directory.
|
||||
mustMkdir(t, filepath.Join(root, "Project", "archive", "PartyA", "mdl"))
|
||||
mustWrite(t, filepath.Join(root, "Project", "archive", "PartyA", "mdl", "row-001.yaml"), "trackingNumber: TR-001\n")
|
||||
got = RecognizeFormRequest(root, "GET", "/Project/archive/PartyA/mdl/row-001.yaml.html")
|
||||
// Re-edit (<id>.yaml.html) at mdl/<party>/ — same default spec
|
||||
// applies. The data file itself must exist on disk; the spec is the
|
||||
// embedded default in the same directory.
|
||||
mustMkdir(t, filepath.Join(root, "Project", "mdl", "PartyA"))
|
||||
mustWrite(t, filepath.Join(root, "Project", "mdl", "PartyA", "row-001.yaml"), "trackingNumber: TR-001\n")
|
||||
got = RecognizeFormRequest(root, "GET", "/Project/mdl/PartyA/row-001.yaml.html")
|
||||
if got == nil || got.Kind != "render-edit" {
|
||||
t.Fatalf("GET row-001.yaml.html: got %+v want render-edit", got)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,18 @@ func historyTestSetup(t *testing.T) (config.Config, func(method, target, email s
|
|||
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Register the party the mdl/rsk tests use (party_source: ssr). The
|
||||
// SSR-history test registers its own party (0330C1) by creating the
|
||||
// ssr row, so it's intentionally left out here.
|
||||
{
|
||||
f := filepath.Join(root, "Project", "ssr", "ACM.yaml")
|
||||
if err := os.MkdirAll(filepath.Dir(f), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(f, []byte("kind: SSR\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
||||
|
||||
|
|
@ -64,7 +76,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
|
|||
// Build a body with the right components for the embedded
|
||||
// mdl rule's filename_format.
|
||||
body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Test spec\n")
|
||||
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
|
||||
url := "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml"
|
||||
|
||||
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
|
||||
if rec.Code != http.StatusCreated {
|
||||
|
|
@ -93,7 +105,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
|
|||
}
|
||||
|
||||
// On-disk file matches the response body.
|
||||
abs := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", "ACM-PRJ-EL-SPC-0001.yaml")
|
||||
abs := filepath.Join(cfg.Root, "Project", "mdl", "ACM", "ACM-PRJ-EL-SPC-0001.yaml")
|
||||
disk, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
t.Fatalf("read disk: %v", err)
|
||||
|
|
@ -103,7 +115,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
|
|||
}
|
||||
|
||||
// No history dir yet (create only).
|
||||
histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history")
|
||||
histDir := filepath.Join(cfg.Root, "Project", "mdl", "ACM", ".zddc.d", "history")
|
||||
if _, err := os.Stat(histDir); !os.IsNotExist(err) {
|
||||
t.Errorf(".history/ should not exist after create-only; got err=%v", err)
|
||||
}
|
||||
|
|
@ -114,7 +126,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
|
|||
// chains previous_sha, and increments revision.
|
||||
func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
|
||||
cfg, do := historyTestSetup(t)
|
||||
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
|
||||
url := "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml"
|
||||
|
||||
body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n")
|
||||
rec := do(http.MethodPut, url, "alice@example.com", body1, nil)
|
||||
|
|
@ -149,7 +161,7 @@ func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
|
|||
}
|
||||
|
||||
// .history/ACM-PRJ-EL-SPC-0001/ has exactly one entry (the v1 bytes).
|
||||
histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history", "ACM-PRJ-EL-SPC-0001")
|
||||
histDir := filepath.Join(cfg.Root, "Project", "mdl", "ACM", ".zddc.d", "history", "ACM-PRJ-EL-SPC-0001")
|
||||
ents, err := os.ReadDir(histDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read history dir: %v", err)
|
||||
|
|
@ -171,7 +183,7 @@ func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
|
|||
// write anything — no history entry, no overwrite.
|
||||
func TestRecordPut_ConflictPreservesHistory(t *testing.T) {
|
||||
cfg, do := historyTestSetup(t)
|
||||
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
|
||||
url := "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml"
|
||||
|
||||
body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n")
|
||||
if rec := do(http.MethodPut, url, "alice@example.com", body1, nil); rec.Code != http.StatusCreated {
|
||||
|
|
@ -186,7 +198,7 @@ func TestRecordPut_ConflictPreservesHistory(t *testing.T) {
|
|||
t.Fatalf("expected 412, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history")
|
||||
histDir := filepath.Join(cfg.Root, "Project", "mdl", "ACM", ".zddc.d", "history")
|
||||
if _, err := os.Stat(histDir); !os.IsNotExist(err) {
|
||||
t.Errorf("history dir should not exist after 412 conflict; got err=%v", err)
|
||||
}
|
||||
|
|
@ -196,7 +208,7 @@ func TestRecordPut_ConflictPreservesHistory(t *testing.T) {
|
|||
// audit fields → server silently strips and overwrites them.
|
||||
func TestRecordPut_ClientAuditFieldsStripped(t *testing.T) {
|
||||
_, do := historyTestSetup(t)
|
||||
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
|
||||
url := "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml"
|
||||
|
||||
body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Forged\n" +
|
||||
"created_by: eve@evil.com\nupdated_by: eve@evil.com\nrevision: 999\n")
|
||||
|
|
@ -221,7 +233,7 @@ func TestRecordPut_ClientAuditFieldsStripped(t *testing.T) {
|
|||
func TestRecordPut_FilenameMismatch(t *testing.T) {
|
||||
_, do := historyTestSetup(t)
|
||||
// URL claims sequence=0002 but body says 0001 → mismatch.
|
||||
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0002.yaml"
|
||||
url := "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0002.yaml"
|
||||
body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: X\n")
|
||||
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
|
|
@ -240,7 +252,7 @@ func TestAugmentSchema_OriginatorReadOnlyAndPrefilled(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
gateDir := filepath.Join(root, "Project", "archive", "ACM", "mdl")
|
||||
gateDir := filepath.Join(root, "Project", "mdl", "ACM")
|
||||
if err := os.MkdirAll(gateDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -276,13 +288,13 @@ func TestRecordPut_OriginatorBoundToPartyFolder(t *testing.T) {
|
|||
// Body claims originator=WRONG; the party folder is ACM. The URL
|
||||
// filename correctly uses the folder name, so the server overwrites
|
||||
// the body field and the write succeeds.
|
||||
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
|
||||
url := "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml"
|
||||
body := []byte("originator: WRONG\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: X\n")
|
||||
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
abs := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", "ACM-PRJ-EL-SPC-0001.yaml")
|
||||
abs := filepath.Join(cfg.Root, "Project", "mdl", "ACM", "ACM-PRJ-EL-SPC-0001.yaml")
|
||||
disk, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
t.Fatalf("read disk: %v", err)
|
||||
|
|
@ -297,7 +309,7 @@ func TestRecordPut_OriginatorBoundToPartyFolder(t *testing.T) {
|
|||
|
||||
// A URL whose filename uses a different originator than the folder
|
||||
// can't be composed to match — 422 filename mismatch.
|
||||
badURL := "/Project/archive/ACM/mdl/WRONG-PRJ-EL-SPC-0002.yaml"
|
||||
badURL := "/Project/mdl/ACM/WRONG-PRJ-EL-SPC-0002.yaml"
|
||||
badBody := []byte("originator: WRONG\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0002'\ntitle: X\n")
|
||||
rec = do(http.MethodPut, badURL, "alice@example.com", badBody, nil)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
|
|
@ -310,7 +322,7 @@ func TestRecordPut_OriginatorBoundToPartyFolder(t *testing.T) {
|
|||
// path=/type.
|
||||
func TestRecordPut_LockedFieldRejected(t *testing.T) {
|
||||
_, do := historyTestSetup(t)
|
||||
url := "/Project/archive/ACM/rsk/ACM-PRJ-EL-RSK-0001-001.yaml"
|
||||
url := "/Project/rsk/ACM/ACM-PRJ-EL-RSK-0001-001.yaml"
|
||||
// Client tries type=SPC even though rsk/ locks type=RSK.
|
||||
body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\nrow: '001'\ntitle: X\n")
|
||||
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
|
||||
|
|
@ -335,21 +347,13 @@ func TestRecordPut_LockedFieldRejected(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestRecordPut_SSRHistoryAtPartyLevel: writing to an SSR row's
|
||||
// canonical archive/<party>/ssr.yaml puts history at
|
||||
// archive/<party>/.history/ssr/, NOT at archive/.history/<party>/.
|
||||
func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) {
|
||||
// TestRecordPut_SSRHistory: writing to an SSR registry row
|
||||
// (ssr/<party>.yaml) puts record-history under ssr/.zddc.d/history/<party>/.
|
||||
func TestRecordPut_SSRHistory(t *testing.T) {
|
||||
cfg, do := historyTestSetup(t)
|
||||
// We bypass the SSR create handler and just PUT directly to the
|
||||
// canonical path the SSR rewrites would land on.
|
||||
abs := filepath.Join(cfg.Root, "Project", "archive", "0330C1")
|
||||
if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The plain file API uses the bytes as-is; ssr.yaml's records:
|
||||
// rule will trigger audit stamping but no filename composition
|
||||
// (no filename_format on the SSR records: entry).
|
||||
url := "/Project/archive/0330C1/ssr.yaml"
|
||||
// PUT directly to the registry row (ssr/ has no party_source, so this
|
||||
// IS the party-registration write).
|
||||
url := "/Project/ssr/0330C1.yaml"
|
||||
body := []byte("kind: SSR\nvendorType: subcontractor\ncontractNo: PO-001\nscopeSummary: Concrete\n")
|
||||
if rec := do(http.MethodPut, url, "alice@example.com", body, nil); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("first put status=%d body=%s", rec.Code, rec.Body.String())
|
||||
|
|
@ -364,15 +368,11 @@ func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) {
|
|||
t.Fatalf("second put status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// History at archive/0330C1/.history/ssr/, NOT at archive/.history/.
|
||||
wanted := filepath.Join(cfg.Root, "Project", "archive", "0330C1", ".zddc.d", "history", "ssr")
|
||||
// Record-history lives at ssr/.zddc.d/history/0330C1/ (the row's own dir).
|
||||
wanted := filepath.Join(cfg.Root, "Project", "ssr", ".zddc.d", "history", "0330C1")
|
||||
if _, err := os.Stat(wanted); err != nil {
|
||||
t.Fatalf("expected history at %s; err=%v", wanted, err)
|
||||
}
|
||||
bad := filepath.Join(cfg.Root, "Project", "archive", ".zddc.d", "history")
|
||||
if _, err := os.Stat(bad); !os.IsNotExist(err) {
|
||||
t.Errorf("history must NOT live at %s; err=%v", bad, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRollupCreate_AssignsRowAndComposesFilename: posting an rsk
|
||||
|
|
@ -381,11 +381,15 @@ func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) {
|
|||
// row number within the table-scope group.
|
||||
func TestRollupCreate_AssignsRowAndComposesFilename(t *testing.T) {
|
||||
cfg, _ := historyTestSetup(t)
|
||||
// Materialize the party folder (rollup create requires it).
|
||||
partyAbs := filepath.Join(cfg.Root, "Project", "archive", "0330C1")
|
||||
if err := os.MkdirAll(partyAbs, 0o755); err != nil {
|
||||
// Register the party (rollup create requires it via party_source: ssr).
|
||||
regF := filepath.Join(cfg.Root, "Project", "ssr", "0330C1.yaml")
|
||||
if err := os.MkdirAll(filepath.Dir(regF), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(regF, []byte("kind: SSR\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(cfg.Root)
|
||||
|
||||
// First row: table-tracking components + the routing party field.
|
||||
// originator is omitted — the server derives it from the party
|
||||
|
|
@ -423,7 +427,7 @@ func TestRollupCreate_AssignsRowAndComposesFilename(t *testing.T) {
|
|||
}
|
||||
|
||||
// All three files contain audit fields (proves WriteWithHistory ran).
|
||||
rskDir := filepath.Join(partyAbs, "rsk")
|
||||
rskDir := filepath.Join(cfg.Root, "Project", "rsk", "0330C1")
|
||||
ents, err := os.ReadDir(rskDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -461,7 +465,7 @@ func TestInDirCreate_RecordComposesAndStampsAudit(t *testing.T) {
|
|||
cfg, _ := historyTestSetup(t)
|
||||
// originator is omitted on purpose — it's folder-bound to ACM.
|
||||
body := `{"project":"PRJ","discipline":"EL","type":"SPC","sequence":"0001","title":"Switchgear spec"}`
|
||||
rec := doForm(t, cfg, "POST", "/Project/archive/ACM/mdl/form.html", "alice@example.com", body)
|
||||
rec := doForm(t, cfg, "POST", "/Project/mdl/ACM/form.html", "alice@example.com", body)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
|
@ -469,7 +473,7 @@ func TestInDirCreate_RecordComposesAndStampsAudit(t *testing.T) {
|
|||
if !strings.Contains(loc, "ACM-PRJ-EL-SPC-0001.yaml") {
|
||||
t.Errorf("location=%q want composed ACM-PRJ-EL-SPC-0001.yaml (not a date+email name)", loc)
|
||||
}
|
||||
abs := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", "ACM-PRJ-EL-SPC-0001.yaml")
|
||||
abs := filepath.Join(cfg.Root, "Project", "mdl", "ACM", "ACM-PRJ-EL-SPC-0001.yaml")
|
||||
disk, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
t.Fatalf("read disk: %v", err)
|
||||
|
|
@ -492,7 +496,7 @@ func TestInDirCreate_RecordComposesAndStampsAudit(t *testing.T) {
|
|||
// in-place tracking-number change (identity is the filename).
|
||||
func TestInDirUpdate_RecordStampsAuditAndRejectsRename(t *testing.T) {
|
||||
cfg, do := historyTestSetup(t)
|
||||
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
|
||||
url := "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml"
|
||||
seed := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n")
|
||||
if rec := do(http.MethodPut, url, "alice@example.com", seed, nil); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("seed status=%d body=%s", rec.Code, rec.Body.String())
|
||||
|
|
@ -501,11 +505,11 @@ func TestInDirUpdate_RecordStampsAuditAndRejectsRename(t *testing.T) {
|
|||
// Same components, new title → revision bumps to 2 (proves the form
|
||||
// update went through WriteWithHistory, not a plain WriteAtomic).
|
||||
upd := `{"originator":"ACM","project":"PRJ","discipline":"EL","type":"SPC","sequence":"0001","title":"V2"}`
|
||||
rec := doForm(t, cfg, "POST", "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml.html", "bob@example.com", upd)
|
||||
rec := doForm(t, cfg, "POST", "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml.html", "bob@example.com", upd)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
disk, _ := os.ReadFile(filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", "ACM-PRJ-EL-SPC-0001.yaml"))
|
||||
disk, _ := os.ReadFile(filepath.Join(cfg.Root, "Project", "mdl", "ACM", "ACM-PRJ-EL-SPC-0001.yaml"))
|
||||
out := map[string]any{}
|
||||
if err := yaml.Unmarshal(disk, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -520,7 +524,7 @@ func TestInDirUpdate_RecordStampsAuditAndRejectsRename(t *testing.T) {
|
|||
// Editing a tracking-number component in place → 422 (composed name
|
||||
// would differ from the file's name).
|
||||
rename := `{"originator":"ACM","project":"PRJ","discipline":"EL","type":"SPC","sequence":"0099","title":"V3"}`
|
||||
rec = doForm(t, cfg, "POST", "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml.html", "bob@example.com", rename)
|
||||
rec = doForm(t, cfg, "POST", "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml.html", "bob@example.com", rename)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("expected 422 for in-place component edit, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,10 +60,10 @@ const opPlanReview = "plan-review"
|
|||
|
||||
// planReviewRequest is the YAML body the browse client POSTs.
|
||||
type planReviewRequest struct {
|
||||
ReviewLead string `yaml:"review_lead"`
|
||||
Approver string `yaml:"approver"`
|
||||
PlanReviewCompleteDate string `yaml:"plan_review_complete_date"`
|
||||
PlanResponseDate string `yaml:"plan_response_date"`
|
||||
ReviewLead string `yaml:"review_lead"`
|
||||
Approver string `yaml:"approver"`
|
||||
PlanReviewCompleteDate string `yaml:"plan_review_complete_date"`
|
||||
PlanResponseDate string `yaml:"plan_response_date"`
|
||||
}
|
||||
|
||||
// planReviewResponse is the JSON returned to the client.
|
||||
|
|
@ -143,12 +143,12 @@ func executePlanReview(cfg config.Config, r *http.Request, project, party, track
|
|||
receivedAbs := filepath.Join(cfg.Root, project, filepath.FromSlash(receivedRel))
|
||||
cleanURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"
|
||||
|
||||
// Hardcoded path convention. Every project has exactly one
|
||||
// reviewing/ and one staging/ slot per party at fixed offsets;
|
||||
// the composite endpoint scaffolds inside the originating party's
|
||||
// slots.
|
||||
reviewingRoot := filepath.Join(cfg.Root, project, "archive", party, "reviewing")
|
||||
stagingRoot := filepath.Join(cfg.Root, project, "archive", party, "staging")
|
||||
// Hardcoded path convention. The composite endpoint scaffolds a
|
||||
// submittal folder inside the top-level reviewing/<party>/ and
|
||||
// staging/<party>/ peers; each carries received_path back to the
|
||||
// canonical record in archive/<party>/received/<tracking>.
|
||||
reviewingRoot := filepath.Join(cfg.Root, project, "reviewing", party)
|
||||
stagingRoot := filepath.Join(cfg.Root, project, "staging", party)
|
||||
|
||||
// Pre-flight authorisation. No ACL exception — we use existing
|
||||
// cascade grants:
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ func TestPlanReview_Idempotent(t *testing.T) {
|
|||
}
|
||||
|
||||
// Confirm no duplicate folders snuck in.
|
||||
reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
|
||||
reviewingRoot := filepath.Join(root, "Project-1", "reviewing", "Acme")
|
||||
entries, err := os.ReadDir(reviewingRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", reviewingRoot, err)
|
||||
|
|
@ -252,7 +252,7 @@ func TestPlanReview_ReceivedZddcIsWriteOnce(t *testing.T) {
|
|||
}
|
||||
|
||||
// reviewing/.zddc reflects the new review_lead.
|
||||
reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
|
||||
reviewingRoot := filepath.Join(root, "Project-1", "reviewing", "Acme")
|
||||
entries, err := os.ReadDir(reviewingRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", reviewingRoot, err)
|
||||
|
|
@ -279,7 +279,7 @@ func TestPlanReview_Forbidden(t *testing.T) {
|
|||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
|
||||
reviewingRoot := filepath.Join(root, "Project-1", "reviewing", "Acme")
|
||||
if _, err := os.Stat(reviewingRoot); err == nil {
|
||||
// reviewing/ should not have been materialised. The mkdir
|
||||
// happens AFTER the ACL check in the handler, so refusal
|
||||
|
|
|
|||
|
|
@ -98,67 +98,37 @@ func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWrit
|
|||
return
|
||||
}
|
||||
|
||||
partyAbs := filepath.Join(cfg.Root, req.Project, "archive", name)
|
||||
if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) {
|
||||
rowURL := "/" + req.Project + "/ssr/" + name + ".yaml"
|
||||
yamlAbs := filepath.Join(cfg.Root, req.Project, "ssr", name+".yaml")
|
||||
if !strings.HasPrefix(yamlAbs, cfg.Root+string(filepath.Separator)) {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
partyURL := "/" + req.Project + "/archive/" + name + "/"
|
||||
rowURL := "/" + req.Project + "/ssr/" + name + ".yaml"
|
||||
|
||||
// ACL gate: create at <project>/archive/<name>/. authorizeAction walks
|
||||
// up to the closest existing ancestor for the chain — typically
|
||||
// <project>/archive/, where document_controller carries rwc per the
|
||||
// project-level cascade.
|
||||
if !authorizeAction(cfg, w, r, partyAbs, partyURL, policy.ActionCreate) {
|
||||
// ACL gate: create the registry row at <project>/ssr/<name>.yaml.
|
||||
// authorizeAction walks to the closest existing ancestor (ssr/ or the
|
||||
// project), where document_controller carries rwc per the cascade.
|
||||
// Creating this file IS registering the party.
|
||||
if !authorizeAction(cfg, w, r, yamlAbs, rowURL, policy.ActionCreate) {
|
||||
return
|
||||
}
|
||||
|
||||
// Refuse to clobber an existing party folder. The SSR view shows
|
||||
// any folder under archive/*/; if one with this name exists, the
|
||||
// user should edit that row instead of creating a duplicate.
|
||||
if info, err := os.Stat(partyAbs); err == nil {
|
||||
if info.IsDir() {
|
||||
http.Error(w, "Conflict — a party folder with that name already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Conflict — a file exists at this path", http.StatusConflict)
|
||||
// Refuse to clobber an existing registration — edit that row instead.
|
||||
if _, err := os.Stat(yamlAbs); err == nil {
|
||||
http.Error(w, "Conflict — party \""+name+"\" is already registered", http.StatusConflict)
|
||||
return
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Materialize canonical ancestors (<project>/archive/) with auto-own
|
||||
// seeding before creating the party folder itself. Mirrors what the
|
||||
// generic file-API mkdir does at fileapi.go:629-634.
|
||||
yamlAbs := filepath.Join(partyAbs, "ssr.yaml")
|
||||
// Materialize the ssr/ ancestor before writing the registry row.
|
||||
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, yamlAbs, email, 0o755); err != nil {
|
||||
slog.Warn("ssr-create: ensure canonical ancestors", "path", yamlAbs, "err", err)
|
||||
}
|
||||
if err := os.MkdirAll(partyAbs, 0o755); err != nil {
|
||||
auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, 0, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Auto-own .zddc on the new party folder. archive/*/ is declared
|
||||
// auto_own in defaults.zddc.yaml, so the unfenced creator grant
|
||||
// fires here exactly as it would for a manual mkdir.
|
||||
if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) {
|
||||
roles := zddc.AutoOwnRolesAt(cfg.Root, partyAbs)
|
||||
var werr error
|
||||
if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) {
|
||||
werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email, roles)
|
||||
} else {
|
||||
werr = zddc.WriteAutoOwnZddc(partyAbs, email, roles)
|
||||
}
|
||||
if werr != nil {
|
||||
slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the path-derived `name` field — it's the folder name, not
|
||||
// row data. The dispatcher re-injects it on read.
|
||||
// Drop the path-derived `name` field — it's the filename, not row
|
||||
// data. The dispatcher re-injects it on read.
|
||||
delete(dataMap, "name")
|
||||
|
||||
yamlBytes, err := yaml.Marshal(dataMap)
|
||||
|
|
@ -252,27 +222,26 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW
|
|||
return
|
||||
}
|
||||
|
||||
partyAbs := filepath.Join(cfg.Root, req.Project, "archive", party)
|
||||
if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if info, err := os.Stat(partyAbs); err != nil || !info.IsDir() {
|
||||
// The party must be registered (ssr/<party>.yaml exists) before rows
|
||||
// can be filed for it.
|
||||
if !zddc.PartyRegistered(filepath.Join(cfg.Root, req.Project), "ssr", party) {
|
||||
writeValidationErrors(w, []jsonschema.Error{{
|
||||
Path: "/party",
|
||||
Message: "party folder does not exist — create it via the SSR view first",
|
||||
Message: "unknown party — register it via the SSR view first",
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
slotAbs := filepath.Join(partyAbs, req.Slot)
|
||||
slotURL := "/" + req.Project + "/archive/" + party + "/" + req.Slot + "/"
|
||||
rowDirURL := slotURL // The slot folder where the new row lands.
|
||||
_ = rowDirURL // kept for clarity; ACL chain is gated below.
|
||||
slotAbs := filepath.Join(cfg.Root, req.Project, req.Slot, party)
|
||||
if !strings.HasPrefix(slotAbs, cfg.Root+string(filepath.Separator)) {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
slotURL := "/" + req.Project + "/" + req.Slot + "/" + party + "/"
|
||||
|
||||
// ACL gate: create at <project>/archive/<party>/<slot>/. authorizeAction
|
||||
// walks up to the closest existing ancestor (typically <party>/), where
|
||||
// the auto-own .zddc grants the party owner rwcd.
|
||||
// ACL gate: create at <project>/<peer>/<party>/. authorizeAction walks
|
||||
// up to the closest existing ancestor (the peer or project), where
|
||||
// document_controller carries rwcd per the cascade.
|
||||
if !authorizeAction(cfg, w, r, slotAbs, slotURL, policy.ActionCreate) {
|
||||
return
|
||||
}
|
||||
|
|
@ -351,7 +320,7 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW
|
|||
// Route through WriteWithHistory for audit stamping. The
|
||||
// filename_format check inside WriteWithHistory passes because
|
||||
// the path we constructed above used the same composition.
|
||||
res, verrs, herr := WriteWithHistory(cfg, target, "/"+req.Project+"/"+req.Slot+"/"+party+"__"+fname, yamlBytes, email)
|
||||
res, verrs, herr := WriteWithHistory(cfg, target, "/"+req.Project+"/"+req.Slot+"/"+party+"/"+fname, yamlBytes, email)
|
||||
if herr != nil {
|
||||
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, len(yamlBytes), herr)
|
||||
http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError)
|
||||
|
|
@ -363,7 +332,7 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW
|
|||
}
|
||||
finalBody := res.FinalBody
|
||||
|
||||
rowURL := "/" + req.Project + "/" + req.Slot + "/" + party + "__" + fname
|
||||
rowURL := "/" + req.Project + "/" + req.Slot + "/" + party + "/" + fname
|
||||
w.Header().Set("Location", rowURL)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-ZDDC-Source", "rollup-create")
|
||||
|
|
@ -398,8 +367,8 @@ func serveSSRRename(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
src := zddc.ResolveVirtualView(cfg.Root, r.URL.Path)
|
||||
if !src.Resolved || src.Kind != zddc.VirtualViewSSRRow {
|
||||
srcProject, srcParty, ok := parseSSRRowURL(r.URL.Path)
|
||||
if !ok {
|
||||
http.Error(w, "Bad Request — ssr-rename source must be /<project>/ssr/<party>.yaml", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
|
@ -412,69 +381,81 @@ func serveSSRRename(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
if dec, err := url.PathUnescape(dstHeader); err == nil {
|
||||
dstHeader = dec
|
||||
}
|
||||
dst := zddc.ResolveVirtualView(cfg.Root, dstHeader)
|
||||
if !dst.Resolved || dst.Kind != zddc.VirtualViewSSRRow {
|
||||
dstProject, dstParty, ok := parseSSRRowURL(dstHeader)
|
||||
if !ok {
|
||||
http.Error(w, "Bad Request — ssr-rename destination must be /<project>/ssr/<party>.yaml", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if dst.Project != src.Project {
|
||||
if dstProject != srcProject {
|
||||
http.Error(w, "Bad Request — ssr-rename cannot cross projects", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if dst.Party == src.Party {
|
||||
if dstParty == srcParty {
|
||||
http.Error(w, "Bad Request — destination is the same as source", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Source party folder must exist.
|
||||
srcArchive := src.PartyArchive
|
||||
if info, err := os.Stat(srcArchive); err != nil || !info.IsDir() {
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
srcAbs := filepath.Join(cfg.Root, srcProject, "ssr", srcParty+".yaml")
|
||||
dstAbs := filepath.Join(cfg.Root, dstProject, "ssr", dstParty+".yaml")
|
||||
srcURL := "/" + srcProject + "/ssr/" + srcParty + ".yaml"
|
||||
dstURL := "/" + dstProject + "/ssr/" + dstParty + ".yaml"
|
||||
|
||||
// Source registry row must exist; destination must not.
|
||||
if info, err := os.Stat(srcAbs); err != nil || info.IsDir() {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
// Destination must not exist.
|
||||
dstArchive := dst.PartyArchive
|
||||
if _, err := os.Stat(dstArchive); err == nil {
|
||||
http.Error(w, "Conflict — destination party folder already exists", http.StatusConflict)
|
||||
if _, err := os.Stat(dstAbs); err == nil {
|
||||
http.Error(w, "Conflict — party \""+dstParty+"\" is already registered", http.StatusConflict)
|
||||
return
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// ACL: write on src archive, create on dst archive. URLs include
|
||||
// the trailing slash convention used elsewhere for directory ops.
|
||||
srcArchiveURL := "/" + src.Project + "/archive/" + src.Party + "/"
|
||||
dstArchiveURL := "/" + dst.Project + "/archive/" + dst.Party + "/"
|
||||
if !authorizeAction(cfg, w, r, srcArchive, srcArchiveURL, policy.ActionWrite) {
|
||||
// ACL: write on the old registry row, create on the new one (ssr/
|
||||
// grants document_controller rwc).
|
||||
if !authorizeAction(cfg, w, r, srcAbs, srcURL, policy.ActionWrite) {
|
||||
return
|
||||
}
|
||||
if !authorizeAction(cfg, w, r, dstArchive, dstArchiveURL, policy.ActionCreate) {
|
||||
if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) {
|
||||
return
|
||||
}
|
||||
|
||||
// Optional If-Match against the source ssr.yaml etag.
|
||||
if !checkIfMatch(w, r, src.CanonicalAbs) {
|
||||
if !checkIfMatch(w, r, srcAbs) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Rename(srcArchive, dstArchive); err != nil {
|
||||
// Registry-only rename: moves the ssr/<party>.yaml row. This does NOT
|
||||
// move the party's workspace/record folders across the other peers
|
||||
// (archive/, mdl/, working/, …); those keep the old name until moved
|
||||
// separately. Cross-peer party rename is a deliberate later/admin op.
|
||||
if err := os.Rename(srcAbs, dstAbs); err != nil {
|
||||
auditFile(r, "ssr-rename", r.URL.Path, http.StatusInternalServerError, 0, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
newURL := "/" + dst.Project + "/ssr/" + dst.Party + ".yaml"
|
||||
w.Header().Set("Location", newURL)
|
||||
w.Header().Set("X-ZDDC-Destination", newURL)
|
||||
w.Header().Set("Location", dstURL)
|
||||
w.Header().Set("X-ZDDC-Destination", dstURL)
|
||||
w.Header().Set("X-ZDDC-Source", "ssr-rename")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
resp, _ := json.Marshal(map[string]string{"location": newURL})
|
||||
resp, _ := json.Marshal(map[string]string{"location": dstURL})
|
||||
_, _ = w.Write(resp)
|
||||
auditFile(r, "ssr-rename", r.URL.Path, http.StatusOK, 0, nil)
|
||||
}
|
||||
|
||||
// parseSSRRowURL parses /<project>/ssr/<party>.yaml into (project, party).
|
||||
func parseSSRRowURL(urlPath string) (project, party string, ok bool) {
|
||||
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||
if len(parts) != 3 || parts[1] != "ssr" || !strings.HasSuffix(parts[2], ".yaml") {
|
||||
return "", "", false
|
||||
}
|
||||
project = parts[0]
|
||||
party = strings.TrimSuffix(parts[2], ".yaml")
|
||||
if project == "" || !zddc.ValidPartyName(party) {
|
||||
return "", "", false
|
||||
}
|
||||
return project, party, true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,30 +76,22 @@ func TestSSRCreate_HappyPath(t *testing.T) {
|
|||
if loc := rec.Result().Header.Get("Location"); loc != "/Project/ssr/0330C1.yaml" {
|
||||
t.Errorf("Location=%q want /Project/ssr/0330C1.yaml", loc)
|
||||
}
|
||||
// archive/0330C1/ exists.
|
||||
partyDir := filepath.Join(cfg.Root, "Project", "archive", "0330C1")
|
||||
if info, err := os.Stat(partyDir); err != nil || !info.IsDir() {
|
||||
t.Fatalf("party folder not created: err=%v", err)
|
||||
}
|
||||
// .zddc auto-own grant.
|
||||
zf, err := os.ReadFile(filepath.Join(partyDir, ".zddc"))
|
||||
// Registration writes the registry row at ssr/<party>.yaml and does
|
||||
// NOT create an archive party folder (that appears on first filing).
|
||||
rowAbs := filepath.Join(cfg.Root, "Project", "ssr", "0330C1.yaml")
|
||||
yamlBytes, err := os.ReadFile(rowAbs)
|
||||
if err != nil {
|
||||
t.Fatalf("read auto-own .zddc: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(zf), "casey@example.com") {
|
||||
t.Errorf("auto-own .zddc missing creator email; got %s", string(zf))
|
||||
}
|
||||
// ssr.yaml exists and contains the submitted fields but NOT `name`.
|
||||
yamlBytes, err := os.ReadFile(filepath.Join(partyDir, "ssr.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read ssr.yaml: %v", err)
|
||||
t.Fatalf("read ssr/0330C1.yaml: %v", err)
|
||||
}
|
||||
yaml := string(yamlBytes)
|
||||
if !strings.Contains(yaml, "contractNo: PO-001") {
|
||||
t.Errorf("ssr.yaml missing contractNo; got %s", yaml)
|
||||
t.Errorf("ssr row missing contractNo; got %s", yaml)
|
||||
}
|
||||
if strings.Contains(yaml, "name: 0330C1") {
|
||||
t.Errorf("ssr.yaml should not carry path-derived `name` field; got %s", yaml)
|
||||
t.Errorf("ssr row should not carry path-derived `name` field; got %s", yaml)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "archive", "0330C1")); !os.IsNotExist(err) {
|
||||
t.Errorf("registration must not create archive/<party>/; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -146,16 +138,6 @@ func TestSSRRename_HappyPath(t *testing.T) {
|
|||
if rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("setup create failed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Drop an MDL row inside the party folder; it should survive the rename.
|
||||
mdlDir := filepath.Join(cfg.Root, "Project", "archive", "0330C1", "mdl")
|
||||
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mdlDir, "D-001.yaml"), []byte("id: D-001\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zddc.InvalidateCache(filepath.Join(cfg.Root, "Project", "archive", "0330C1"))
|
||||
|
||||
rec := do(http.MethodPost, "/Project/ssr/0330C1.yaml", "casey@example.com", "",
|
||||
map[string]string{
|
||||
"X-ZDDC-Op": opSSRRename,
|
||||
|
|
@ -164,15 +146,13 @@ func TestSSRRename_HappyPath(t *testing.T) {
|
|||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("rename failed: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "archive", "0330C1")); !os.IsNotExist(err) {
|
||||
t.Error("source party folder still exists after rename")
|
||||
// Registry-only rename: the row moves to the new name; folders under
|
||||
// the other peers are intentionally left untouched.
|
||||
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "ssr", "0330C1.yaml")); !os.IsNotExist(err) {
|
||||
t.Error("source registry row still exists after rename")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "archive", "0330C2")); err != nil {
|
||||
t.Errorf("destination party folder not created: %v", err)
|
||||
}
|
||||
// MDL row followed the directory rename.
|
||||
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "archive", "0330C2", "mdl", "D-001.yaml")); err != nil {
|
||||
t.Errorf("MDL row did not survive rename: %v", err)
|
||||
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "ssr", "0330C2.yaml")); err != nil {
|
||||
t.Errorf("destination registry row not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -113,12 +113,11 @@ func DefaultProjectRskFormYAML() []byte { return embeddedDefaultProjectRskForm }
|
|||
// default-spec virtual files served when no operator file exists on
|
||||
// disk. Recognized URL shapes:
|
||||
//
|
||||
// <project>/archive/<party>/mdl/{table.yaml, form.yaml}
|
||||
// <project>/archive/<party>/rsk/{table.yaml, form.yaml}
|
||||
// <project>/archive/<party>/ssr.form.yaml
|
||||
// <project>/ssr/{table.yaml, form.yaml}
|
||||
// <project>/mdl/{table.yaml, form.yaml}
|
||||
// <project>/rsk/{table.yaml, form.yaml}
|
||||
// <project>/mdl/{table.yaml, form.yaml} aggregate (with $party)
|
||||
// <project>/rsk/{table.yaml, form.yaml} aggregate (with $party)
|
||||
// <project>/ssr/{table.yaml, form.yaml} registry table
|
||||
// <project>/mdl/<party>/{table.yaml, form.yaml} per-party
|
||||
// <project>/rsk/<party>/{table.yaml, form.yaml} per-party
|
||||
//
|
||||
// Returns embedded bytes + true when the fallback should fire; nil +
|
||||
// false when an operator file exists at that path or the URL is not
|
||||
|
|
@ -163,14 +162,13 @@ func IsDefaultSpecAbs(fsRoot, absPath string) ([]byte, bool) {
|
|||
func classifyDefaultSpec(rel string) []byte {
|
||||
parts := strings.Split(rel, "/")
|
||||
switch len(parts) {
|
||||
case 5:
|
||||
// <project>/archive/<party>/<slot>/<file>
|
||||
if !strings.EqualFold(parts[1], "archive") {
|
||||
return nil
|
||||
}
|
||||
slot := strings.ToLower(parts[3])
|
||||
file := strings.ToLower(parts[4])
|
||||
switch slot {
|
||||
case 4:
|
||||
// <project>/<peer>/<party>/<file> — per-party register specs
|
||||
// (mdl/<party>/, rsk/<party>/). The single-party table/form,
|
||||
// no $party column.
|
||||
peer := strings.ToLower(parts[1])
|
||||
file := strings.ToLower(parts[3])
|
||||
switch peer {
|
||||
case "mdl":
|
||||
switch file {
|
||||
case "table.yaml":
|
||||
|
|
@ -186,19 +184,13 @@ func classifyDefaultSpec(rel string) []byte {
|
|||
return embeddedDefaultRskForm
|
||||
}
|
||||
}
|
||||
case 4:
|
||||
// <project>/archive/<party>/<file> — only ssr.form.yaml is virtual.
|
||||
if !strings.EqualFold(parts[1], "archive") {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(parts[3], "ssr.form.yaml") {
|
||||
return embeddedDefaultSsrForm
|
||||
}
|
||||
case 3:
|
||||
// <project>/<slot>/<file> — project-level virtual specs.
|
||||
slot := strings.ToLower(parts[1])
|
||||
// <project>/<peer>/<file> — peer-root specs. ssr/ is a flat
|
||||
// register; mdl/ and rsk/ are the cross-party AGGREGATE tables
|
||||
// (the project-level spec carries the $party column).
|
||||
peer := strings.ToLower(parts[1])
|
||||
file := strings.ToLower(parts[2])
|
||||
switch slot {
|
||||
switch peer {
|
||||
case "ssr":
|
||||
switch file {
|
||||
case "table.yaml":
|
||||
|
|
@ -349,17 +341,14 @@ func classifyVirtualTableDir(fsRoot, dirAbs string) (string, bool) {
|
|||
parts := strings.Split(rel, "/")
|
||||
switch len(parts) {
|
||||
case 2:
|
||||
// <project>/<slot>
|
||||
// <project>/<peer> — aggregate ssr/mdl/rsk table.
|
||||
slot := strings.ToLower(parts[1])
|
||||
if slot == "ssr" || slot == "mdl" || slot == "rsk" {
|
||||
return slot, true
|
||||
}
|
||||
case 4:
|
||||
// <project>/archive/<party>/<slot>
|
||||
if !strings.EqualFold(parts[1], "archive") {
|
||||
return "", false
|
||||
}
|
||||
slot := strings.ToLower(parts[3])
|
||||
case 3:
|
||||
// <project>/<peer>/<party> — per-party mdl/rsk table.
|
||||
slot := strings.ToLower(parts[1])
|
||||
if slot == "mdl" || slot == "rsk" {
|
||||
return slot, true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ func archivePartyTestSetup(t *testing.T, partyZddcExtras string) (string, func(m
|
|||
func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) {
|
||||
_, do := archivePartyTestSetup(t, "")
|
||||
|
||||
rec := do(http.MethodGet, "/Project/archive/Acme/mdl/table.html", "alice@example.com")
|
||||
rec := do(http.MethodGet, "/Project/mdl/Acme/table.html", "alice@example.com")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("default mdl recognition: want 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
|
@ -305,7 +305,7 @@ func TestIsDefaultSpec_MDL_ServesEmbeddedYAML(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/mdl/table.yaml")
|
||||
bts, ok := IsDefaultSpec(root, "/Project/mdl/Acme/table.yaml")
|
||||
if !ok {
|
||||
t.Fatalf("expected fallback to fire")
|
||||
}
|
||||
|
|
@ -313,7 +313,7 @@ func TestIsDefaultSpec_MDL_ServesEmbeddedYAML(t *testing.T) {
|
|||
t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
|
||||
}
|
||||
|
||||
bts, ok = IsDefaultSpec(root, "/Project/archive/Acme/mdl/form.yaml")
|
||||
bts, ok = IsDefaultSpec(root, "/Project/mdl/Acme/form.yaml")
|
||||
if !ok {
|
||||
t.Fatalf("expected form fallback to fire")
|
||||
}
|
||||
|
|
@ -324,14 +324,14 @@ func TestIsDefaultSpec_MDL_ServesEmbeddedYAML(t *testing.T) {
|
|||
|
||||
func TestIsDefaultSpec_MDL_OperatorFileWins(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mdlDir := filepath.Join(root, "Project", "archive", "Acme", "mdl")
|
||||
mdlDir := filepath.Join(root, "Project", "mdl", "Acme")
|
||||
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := IsDefaultSpec(root, "/Project/archive/Acme/mdl/table.yaml"); ok {
|
||||
if _, ok := IsDefaultSpec(root, "/Project/mdl/Acme/table.yaml"); ok {
|
||||
t.Errorf("operator file should win over embedded fallback")
|
||||
}
|
||||
}
|
||||
|
|
@ -340,7 +340,7 @@ func TestIsDefaultSpec_MDL_OnlyAtArchivePartyLevel(t *testing.T) {
|
|||
root := t.TempDir()
|
||||
cases := []string{
|
||||
"/Project/working/mdl/table.yaml",
|
||||
"/Project/archive/mdl/table.yaml", // depth 3 — no party segment
|
||||
"/Project/archive/mdl/table.yaml", // depth 3 — no party segment
|
||||
"/Project/archive/Acme/sub/mdl/table.yaml",
|
||||
}
|
||||
for _, p := range cases {
|
||||
|
|
@ -354,7 +354,7 @@ func TestIsDefaultSpec_MDL_OnlyAtArchivePartyLevel(t *testing.T) {
|
|||
|
||||
func TestRecognizeTableRequest_DefaultRskAtArchiveParty(t *testing.T) {
|
||||
_, do := archivePartyTestSetup(t, "")
|
||||
rec := do(http.MethodGet, "/Project/archive/Acme/rsk/table.html", "alice@example.com")
|
||||
rec := do(http.MethodGet, "/Project/rsk/Acme/table.html", "alice@example.com")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("default rsk recognition: want 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
|
@ -375,14 +375,14 @@ func TestIsDefaultSpec_RSK_ServesEmbeddedYAML(t *testing.T) {
|
|||
if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/rsk/table.yaml")
|
||||
bts, ok := IsDefaultSpec(root, "/Project/rsk/Acme/table.yaml")
|
||||
if !ok {
|
||||
t.Fatalf("expected RSK table fallback to fire")
|
||||
}
|
||||
if !strings.Contains(string(bts), "Risk Register") {
|
||||
t.Errorf("default RSK table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
|
||||
}
|
||||
bts, ok = IsDefaultSpec(root, "/Project/archive/Acme/rsk/form.yaml")
|
||||
bts, ok = IsDefaultSpec(root, "/Project/rsk/Acme/form.yaml")
|
||||
if !ok {
|
||||
t.Fatalf("expected RSK form fallback to fire")
|
||||
}
|
||||
|
|
@ -393,13 +393,13 @@ func TestIsDefaultSpec_RSK_ServesEmbeddedYAML(t *testing.T) {
|
|||
|
||||
func TestIsDefaultSpec_SSR_PerParty(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// archive/<party>/ssr.form.yaml — per-party SSR schema.
|
||||
bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/ssr.form.yaml")
|
||||
// ssr/ is the flat registry; its form spec is /Project/ssr/form.yaml.
|
||||
bts, ok := IsDefaultSpec(root, "/Project/ssr/form.yaml")
|
||||
if !ok {
|
||||
t.Fatalf("expected per-party SSR schema fallback to fire")
|
||||
t.Fatalf("expected SSR schema fallback to fire")
|
||||
}
|
||||
if !strings.Contains(string(bts), "Supplier") {
|
||||
t.Errorf("per-party SSR schema missing expected header")
|
||||
t.Errorf("SSR schema missing expected header")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -440,4 +440,3 @@ func TestIsDefaultSpec_ProjectLevel_OperatorOverrides(t *testing.T) {
|
|||
t.Errorf("operator file should win at /Project/ssr/table.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1534,7 +1534,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 17:46:04 · 2918248</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -3849,34 +3849,58 @@ body.is-elevated::after {
|
|||
return cur;
|
||||
}
|
||||
|
||||
function isRowFile(name) {
|
||||
return name.endsWith('.yaml') && name !== 'table.yaml' && name !== 'form.yaml';
|
||||
}
|
||||
|
||||
// readRows reads a table's rows from rowsDir. A flat directory
|
||||
// (a per-party register like mdl/<party>/ or the ssr/ registry)
|
||||
// yields one row per *.yaml file. An aggregate peer root (mdl/ ,
|
||||
// rsk/) instead contains party SUBDIRS — we recurse ONE level so the
|
||||
// peer root renders the cross-party table. relName carries the
|
||||
// <party>/ prefix for those rows so reads + edit URLs hit the real
|
||||
// per-party path; $party is derived from that prefix (and matches the
|
||||
// server-injected value online). Works in both online + offline modes.
|
||||
async function readRows(rowsDir, _rowsRel, _tableName) {
|
||||
const rows = [];
|
||||
for await (const entry of rowsDir.values()) {
|
||||
if (entry.kind !== 'file') continue;
|
||||
if (!entry.name.endsWith('.yaml')) continue;
|
||||
// Skip the spec and the row-edit form — they live alongside
|
||||
// the rows but aren't rows themselves.
|
||||
if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
|
||||
|
||||
async function pushRow(handle, relName) {
|
||||
try {
|
||||
const handle = await rowsDir.getFileHandle(entry.name);
|
||||
const file = await handle.getFile();
|
||||
const data = window.jsyaml.load(await file.text());
|
||||
const data = window.jsyaml.load(await file.text()) || {};
|
||||
const slash = relName.indexOf('/');
|
||||
if (slash > 0 && typeof data === 'object' && data.$party === undefined) {
|
||||
data['$party'] = relName.slice(0, slash);
|
||||
}
|
||||
rows.push({
|
||||
url: rowEditUrl(entry.name),
|
||||
// Underlying YAML URL — strip the trailing .html
|
||||
// from the form-mode re-edit URL. Phase 3 PUTs to
|
||||
// this URL with If-Match: <etag> for optimistic
|
||||
// concurrency.
|
||||
yamlUrl: rowEditUrl(entry.name).replace(/\.html$/, ''),
|
||||
data: data || {},
|
||||
// ETag captured by HttpFileHandle.getFile from the
|
||||
// server's response header. null in offline / file://
|
||||
// mode (no HTTP roundtrip happened).
|
||||
url: rowEditUrl(relName),
|
||||
// Underlying YAML URL — strip the trailing .html from
|
||||
// the form-mode re-edit URL. PUTs go here with
|
||||
// If-Match: <etag> for optimistic concurrency.
|
||||
yamlUrl: rowEditUrl(relName).replace(/\.html$/, ''),
|
||||
data: data,
|
||||
etag: handle._etag || null,
|
||||
editable: true
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[tables] skipping unparseable row', entry.name, err);
|
||||
console.warn('[tables] skipping unparseable row', relName, err);
|
||||
}
|
||||
}
|
||||
|
||||
for await (const entry of rowsDir.values()) {
|
||||
if (entry.kind === 'file') {
|
||||
if (!isRowFile(entry.name)) continue;
|
||||
await pushRow(await rowsDir.getFileHandle(entry.name), entry.name);
|
||||
continue;
|
||||
}
|
||||
if (entry.kind === 'directory') {
|
||||
let sub;
|
||||
try { sub = await rowsDir.getDirectoryHandle(entry.name); }
|
||||
catch (_e) { continue; }
|
||||
for await (const child of sub.values()) {
|
||||
if (child.kind !== 'file' || !isRowFile(child.name)) continue;
|
||||
await pushRow(await sub.getFileHandle(child.name), entry.name + '/' + child.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
|
|
@ -5418,11 +5442,11 @@ body.is-elevated::after {
|
|||
// contain primitive / string-array values that are safe to
|
||||
// overwrite the corresponding top-level field.
|
||||
//
|
||||
// $-prefixed keys are system-synthesised on read (e.g. `$party`
|
||||
// injected by the server's virtual-view handler on project-
|
||||
// rollup MDL/RSK rows). They are not part of the row's stored
|
||||
// YAML and would be rejected by the schema's additionalProperties
|
||||
// rule. Strip them before sending the write.
|
||||
// $-prefixed keys are system-synthesised on read (e.g. `$party`,
|
||||
// injected by the server on mdl/rsk rows or derived from the
|
||||
// party subdir in the aggregate view). They are not part of the
|
||||
// row's stored YAML and would be rejected by the schema's
|
||||
// additionalProperties rule. Strip them before sending the write.
|
||||
const merged = Object.assign({}, data || {}, drafts || {});
|
||||
for (const k of Object.keys(merged)) {
|
||||
if (k.charAt(0) === '$') delete merged[k];
|
||||
|
|
@ -6152,12 +6176,11 @@ body.is-elevated::after {
|
|||
const targets = inRange ? rangeRows : [ctx.rowId];
|
||||
const items = [];
|
||||
|
||||
// Edit row — opens the schema-driven form-mode editor for
|
||||
// this row. row.url is already the <id>.yaml.html form URL
|
||||
// (the form handler unwraps virtual-view URLs server-side, so
|
||||
// SSR + rollup rows route to their per-party canonical paths
|
||||
// automatically). Disabled on multi-row range and unsaved
|
||||
// draft rows (no backing file yet).
|
||||
// Edit row — opens the schema-driven form-mode editor for this
|
||||
// row. row.url is the real <…>/<id>.yaml.html form URL (it carries
|
||||
// the <party>/ prefix for aggregate rows, so it hits the real
|
||||
// per-party path). Disabled on multi-row range and unsaved draft
|
||||
// rows (no backing file yet).
|
||||
const singleRow = targets.length === 1 ? ctx.row : null;
|
||||
const editUrl = singleRow && !singleRow.isNew && singleRow.url ? singleRow.url : null;
|
||||
items.push({
|
||||
|
|
|
|||
|
|
@ -1,31 +1,23 @@
|
|||
// Package handler — virtualviewhandler.go: GET dispatch for SSR row +
|
||||
// MDL/RSK rollup row URLs.
|
||||
// Package handler — virtualviewhandler.go: GET dispatch for the
|
||||
// aggregate register rows (mdl/<party>/*.yaml, rsk/<party>/*.yaml,
|
||||
// ssr/<party>.yaml).
|
||||
//
|
||||
// These URLs live in the project-level virtual folders (<project>/ssr,
|
||||
// <project>/mdl, <project>/rsk) and rewrite to canonical files inside
|
||||
// <project>/archive/<party>/. The bytes returned to the client are
|
||||
// augmented with a single path-derived field that the canonical file
|
||||
// doesn't carry:
|
||||
// In the flat-peer layout these are REAL files at their own paths; the
|
||||
// only thing the on-disk bytes lack is the path-derived source column
|
||||
// the aggregate table renders:
|
||||
//
|
||||
// - SSR rows get `name: <party>` so the table renderer has a column
|
||||
// to sort on and the form edit pre-fills the party name. (Identity
|
||||
// of an SSR row is the party folder name, so the field is named
|
||||
// plainly rather than sigil-prefixed.)
|
||||
// - MDL / RSK rollup rows get `$party: <party>` so the rollup table
|
||||
// can show which package each row came from. The `$` sigil marks
|
||||
// the field as system-synthesised: tables tool renders it read-
|
||||
// only and the form client strips it before submit, so a user-
|
||||
// defined `party` field on a deliverable row never collides with
|
||||
// the synthetic source-party column.
|
||||
// - MDL / RSK rows get `$party: <party>` so the cross-party rollup
|
||||
// can show which party each row came from. The `$` sigil marks the
|
||||
// field system-synthesised: the tables tool renders it read-only and
|
||||
// the form client strips it before submit, so a user-defined `party`
|
||||
// field never collides with the synthetic source-party column.
|
||||
// - SSR rows get `name: <party>` (the party = the filename) so the
|
||||
// register table has an identity column to sort on and the form edit
|
||||
// pre-fills the party name.
|
||||
//
|
||||
// Both fields are stripped before write-back (SSR via serveFormCreateSSR
|
||||
// strip; MDL/RSK rollup writes go through the generic serveFormUpdate,
|
||||
// where the path-derived `$party:` is rejected by `additionalProperties:
|
||||
// false` in the underlying schema — so the client must strip it on
|
||||
// submit, which the tables/form JS already does for path-derived
|
||||
// fields).
|
||||
//
|
||||
// Listings: see fs/tree.go.
|
||||
// Both fields are path-derived and stripped before write-back by the
|
||||
// tables/form JS (the schema's additionalProperties:false also rejects
|
||||
// `$party` on submit). Listings: see fs/tree.go.
|
||||
|
||||
package handler
|
||||
|
||||
|
|
@ -34,70 +26,45 @@ import (
|
|||
"os"
|
||||
"strconv"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ServeVirtualViewRow serves a GET (or HEAD) for one of the virtual
|
||||
// row URLs. Caller is expected to have already evaluated ACL against
|
||||
// vv.PartyArchive's chain.
|
||||
//
|
||||
// For SSR rows: returns the canonical archive/<party>/ssr.yaml bytes
|
||||
// with `name: <party>` injected. If no canonical file exists yet,
|
||||
// returns `name: <party>\n` (an otherwise-empty row) — the SSR view
|
||||
// shows every party folder whether or not metadata has been written.
|
||||
//
|
||||
// For MDL / RSK rollup rows: returns the canonical bytes with
|
||||
// `party: <party>` injected. If the canonical file doesn't exist
|
||||
// (shouldn't happen — the listing only surfaces real files) returns
|
||||
// 404.
|
||||
func ServeVirtualViewRow(w http.ResponseWriter, r *http.Request, vv zddc.VirtualViewResolution) {
|
||||
if !vv.Resolved || !vv.Kind.IsRowKind() {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(vv.CanonicalAbs)
|
||||
// ServeInjectedRow serves a GET/HEAD for a real register row file with a
|
||||
// single path-derived field injected (field=value). Used by dispatch for
|
||||
// the aggregate register rows; ACL is evaluated by the caller against the
|
||||
// file's own chain. Returns 404 if the file doesn't exist.
|
||||
func ServeInjectedRow(w http.ResponseWriter, r *http.Request, abs, field, value string) {
|
||||
raw, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// File doesn't exist yet.
|
||||
if vv.Kind != zddc.VirtualViewSSRRow {
|
||||
if os.IsNotExist(err) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
raw = nil
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var data map[string]any
|
||||
if len(raw) > 0 {
|
||||
if err := yaml.Unmarshal(raw, &data); err != nil {
|
||||
http.Error(w, "parse canonical yaml: "+err.Error(), http.StatusInternalServerError)
|
||||
http.Error(w, "parse row yaml: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if data == nil {
|
||||
data = make(map[string]any)
|
||||
}
|
||||
switch vv.Kind {
|
||||
case zddc.VirtualViewSSRRow:
|
||||
data["name"] = vv.Party
|
||||
case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow:
|
||||
data["$party"] = vv.Party
|
||||
}
|
||||
data[field] = value
|
||||
|
||||
out, err := yaml.Marshal(data)
|
||||
if err != nil {
|
||||
http.Error(w, "marshal virtual row: "+err.Error(), http.StatusInternalServerError)
|
||||
http.Error(w, "marshal row: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-ZDDC-Source", "virtual-view-row")
|
||||
w.Header().Set("X-ZDDC-Resolved-Path", vv.CanonicalURL)
|
||||
w.Header().Set("X-ZDDC-Source", "register-row")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(out)))
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ func TestPutToIssuedAsUnelevatedNonAdminUserDenied(t *testing.T) {
|
|||
" document_controller:\n members: [alice@example.com, bob@example.com]\n"+
|
||||
" project_team:\n members: [\"*@example.com\", \"*@bitnest.cc\"]\n")
|
||||
|
||||
// Register the party (party_source: ssr) so the write reaches the WORM
|
||||
// check this test exercises rather than the registration gate.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Project-1/ssr"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir ssr: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Project-1/ssr/PartyA.yaml"), []byte("kind: SSR\n"), 0o644); err != nil {
|
||||
t.Fatalf("register party: %v", err)
|
||||
}
|
||||
// Materialise the exact path shape from the bitnest log entry.
|
||||
issuedDir := filepath.Join(root, "Project-1/archive/PartyA/issued/2025-09-21_A-FAC2-PM-DRW-0377 (RSB) - Test")
|
||||
if err := os.MkdirAll(issuedDir, 0o755); err != nil {
|
||||
|
|
|
|||
|
|
@ -341,19 +341,14 @@ func TestServeZddcFile_Effective_RoleMemberUnion(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// TestServeZddcFile_VirtualWorkingPeer — the working/ peer declared by
|
||||
// the embedded defaults shows its rich config in the synthesized virtual
|
||||
// .zddc: default_tool, available_tools (classifier), party_source, history.
|
||||
func TestServeZddcFile_VirtualWorkingPeer(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 := httptest.NewRequest(http.MethodGet, "/Project/working/.zddc", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
||||
rec := httptest.NewRecorder()
|
||||
ServeZddcFile(cfg, rec, req)
|
||||
|
|
@ -364,13 +359,11 @@ func TestServeZddcFile_VirtualPerPartyWorking(t *testing.T) {
|
|||
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
|
||||
"party_source: ssr", // party gating
|
||||
"classifier", // available_tools includes classifier
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q at archive/<party>/working/: %s", want, body)
|
||||
t.Errorf("body missing %q at working/ peer: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -406,6 +406,7 @@ func nonZeroZddcFields(zf ZddcFile) []string {
|
|||
add("auto_own_fenced", zf.AutoOwnFenced != nil)
|
||||
add("virtual", zf.Virtual != nil)
|
||||
add("drop_target", zf.DropTarget != nil)
|
||||
add("party_source", zf.PartySource != "")
|
||||
add("history", zf.History != nil)
|
||||
add("history_globs", len(zf.HistoryGlobs) > 0)
|
||||
add("worm", zf.Worm != nil)
|
||||
|
|
|
|||
|
|
@ -23,72 +23,43 @@ acl:
|
|||
# ── Standard roles ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# Three roles ship empty (no members) — a fresh deployment grants
|
||||
# nothing until an operator populates them. They're referenced by the
|
||||
# project-scoped grants in paths: below.
|
||||
# nothing until an operator populates them. Membership UNIONS across
|
||||
# the cascade; use `reset: true` at a subtree to start fresh.
|
||||
#
|
||||
# Role membership UNIONS across the cascade: an on-disk .zddc that
|
||||
# defines `project_team` again with one extra member ADDS that member
|
||||
# to the inherited role. To start fresh at a subtree (e.g. a project
|
||||
# wanting its own team independent of a deployment-wide default), use
|
||||
# `reset: true` on the role at that level — ancestor definitions above
|
||||
# the reset are then excluded.
|
||||
# document_controller — owns the committed record and the party
|
||||
# registry. They:
|
||||
# - register parties: a party EXISTS iff ssr/<party>.yaml exists,
|
||||
# and the DC creates it (rwc at ssr/). This is the single
|
||||
# source of truth for party existence.
|
||||
# - file write-once into the WORM archive: read + create at
|
||||
# archive/<party>/received and issued via the worm: list (the
|
||||
# WORM mask strips w/d/a; create survives only for listed
|
||||
# principals). archive/ also grants rwc so the DC can create
|
||||
# party record dirs.
|
||||
# - rwcda across the live workspaces (incoming/working/staging/
|
||||
# reviewing), restated per-peer so a DC matched by the
|
||||
# project_team wildcard keeps full authority via within-level
|
||||
# union.
|
||||
# NOT a subtree-admin anywhere — no admins: entry. DCs cannot
|
||||
# bypass WORM (only worm-create); admin elevation is reserved for
|
||||
# the root admins: list (the human escape hatch for mis-filed
|
||||
# documents or recovery).
|
||||
#
|
||||
# document_controller — the people who file into
|
||||
# archive/<party>/received/ and issued/ (WORM zones). They get:
|
||||
# - rwcda at every archive/<party>/ via the role grant written
|
||||
# into each party's auto-own .zddc (auto_own_roles below).
|
||||
# Cascade carries rwcda down to descendants by default.
|
||||
# - read+write-once-create at received/issued via the worm:
|
||||
# lists (the WORM mask strips w/d/a even though the role
|
||||
# grant supplies rwcda at the party level above).
|
||||
# - rwcd explicit at incoming/ and staging/ (the QC and
|
||||
# transmittal-out workflows need `d` to move files between
|
||||
# slots; the explicit grants shadow the inherited rwcda
|
||||
# to make the intent visible).
|
||||
# - rwc at archive/ so they can create party subfolders.
|
||||
# project_team — everyone working on a project. Read across the
|
||||
# project, with a one-way ratchet through the live workspaces:
|
||||
# working/ cr create + read; auto_own gives the creator
|
||||
# rwcda inside the party folder they make
|
||||
# staging/ cr drop + read, no modify after the drop
|
||||
# reviewing/ cr create + read review iterations
|
||||
# incoming/ r counterparty's drop zone (observe)
|
||||
# archive/ r the committed record (received/issued), WORM
|
||||
# ssr/mdl/rsk r registry + registers (the DC maintains them)
|
||||
# Each handoff drops the role's modify rights for the previous
|
||||
# stage.
|
||||
#
|
||||
# NOT a subtree-admin anywhere. There is no `admins:` entry for
|
||||
# the role — DCs cannot bypass WORM (only worm-create via the
|
||||
# list) and cannot reach inside fenced working homes. Admin
|
||||
# elevation is reserved for the root admins: list (the human
|
||||
# escape hatch for mis-filed documents or recovery).
|
||||
#
|
||||
# Plan-Review approval is part of this role by design — there is
|
||||
# no separate `approver` role; two-person sign-off, when needed,
|
||||
# is expressed via per-folder `.zddc` overrides rather than
|
||||
# baked-in roles.
|
||||
#
|
||||
# project_team — everyone working on a project. Read-only across
|
||||
# the project by default, with a one-way ratchet through the
|
||||
# in-flight slots:
|
||||
#
|
||||
# working/ cr — create + read; the auto_own_fenced child
|
||||
# gives the creator rwcda in their own home,
|
||||
# fenced from siblings
|
||||
# staging/ cr — drop + read, no modify (after drop, the
|
||||
# doc-controller is the only one who can
|
||||
# change it)
|
||||
# reviewing/ cr — create + read; auto_own (unfenced) gives
|
||||
# creator rwcda in their iteration folder,
|
||||
# siblings see it via project-level :r
|
||||
# received/ r — WORM zone; only document_controller can
|
||||
# file (and even they need elevation to edit)
|
||||
# issued/ r — WORM zone; published, immutable
|
||||
# incoming/ r — counterparty's drop zone (project_team
|
||||
# observers it, doc_controller QCs it)
|
||||
#
|
||||
# "Each handoff drops the role's modify rights for the previous
|
||||
# slot." That's the model — project_team works freely in
|
||||
# working/, commits to staging/, and from there the doc-
|
||||
# controller takes over.
|
||||
#
|
||||
# observer — pure read-only across the project. Like project_team
|
||||
# but with no auto-own home: an observer who somehow created a
|
||||
# working/<email>/ would still own it via auto-own (the mechanism
|
||||
# is path-keyed, not role-keyed), but since observer lacks `c`
|
||||
# anywhere, the situation doesn't arise in practice. Intended for
|
||||
# auditors, regulators, and external read-only viewers who must
|
||||
# not contribute content.
|
||||
# observer — pure read-only across the project; no create anywhere.
|
||||
# Intended for auditors, regulators, and external read-only
|
||||
# viewers who must not contribute content.
|
||||
roles:
|
||||
document_controller:
|
||||
members: []
|
||||
|
|
@ -99,481 +70,243 @@ roles:
|
|||
|
||||
# Universal tool baseline. archive (record browser), browse (file
|
||||
# tree, hosts the in-place markdown editor), and landing (project
|
||||
# picker) work everywhere. Each canonical folder below adds its own
|
||||
# context-specific tools (transmittal in staging/, etc.). The cascade
|
||||
# unions available_tools across all levels — leaf restrictions don't
|
||||
# drop ancestor entries — so this baseline propagates to every
|
||||
# descendant.
|
||||
# picker) work everywhere. Each peer below adds its own tools
|
||||
# (transmittal in staging/, tables in mdl/rsk/ssr, etc.). available_tools
|
||||
# UNIONS across the cascade — leaf restrictions don't drop ancestor
|
||||
# entries — so this baseline propagates to every descendant.
|
||||
available_tools: [archive, browse, landing]
|
||||
|
||||
# ── The slash / no-slash routing convention ────────────────────────────────
|
||||
#
|
||||
# Every directory URL has two forms, each served by a configurable
|
||||
# tool:
|
||||
# Every directory URL has two forms:
|
||||
#
|
||||
# <dir>/ (trailing slash) → `dir_tool` — the directory view.
|
||||
# Defaults to `browse` (file-tree
|
||||
# navigator). This is the site-wide
|
||||
# default; you rarely set it.
|
||||
# <dir> (no slash) → `default_tool` — the "specialized
|
||||
# app" for this folder (e.g. archive,
|
||||
# transmittal, tables). If a folder
|
||||
# declares no default_tool, the no-
|
||||
# slash form just 302s to the slash
|
||||
# form, so you land on `dir_tool`.
|
||||
# <dir>/ (trailing slash) → `dir_tool` — the directory view
|
||||
# (defaults to `browse`, the file-tree
|
||||
# navigator; you rarely set it).
|
||||
# <dir> (no slash) → `default_tool` — the specialized app
|
||||
# for this folder (archive, transmittal,
|
||||
# tables). If a folder declares no
|
||||
# default_tool, the no-slash form 302s
|
||||
# to the slash form.
|
||||
#
|
||||
# JSON listing requests are unaffected by either key — they always get
|
||||
# the raw directory listing, so the browse SPA (and any other client)
|
||||
# can enumerate entries no matter what dir_tool/default_tool are.
|
||||
# JSON listing requests are unaffected — they always get the raw
|
||||
# directory listing, so the browse SPA (and any client) can enumerate
|
||||
# entries regardless of dir_tool/default_tool. Both keys cascade
|
||||
# leaf→root.
|
||||
#
|
||||
# Both keys cascade leaf→root: a parent's default_tool applies to
|
||||
# descendants unless a deeper level overrides it (browse set on
|
||||
# working/ reaches working/alice/notes/ for free). The keys below set
|
||||
# default_tool on the canonical folders; dir_tool is left unset
|
||||
# everywhere, so the slash form is always `browse`.
|
||||
# ── Canonical project structure (top-level party peers) ─────────────────────
|
||||
#
|
||||
# ── Canonical project structure ────────────────────────────────────────────
|
||||
# A project is a top-level directory. Under it sit a FLAT set of
|
||||
# physical, party-partitioned peers — there are no virtual aggregators:
|
||||
#
|
||||
# Every ZDDC project lives at a top-level directory. Under it
|
||||
# `archive/` is the ONLY real top-level folder; it contains a folder
|
||||
# per party. Everything party-scoped (the SSR row, MDL/RSK rollups,
|
||||
# WORM received/issued, the incoming drop zone, and the in-flight
|
||||
# lifecycle slots working/staging/reviewing) lives uniformly under
|
||||
# archive/<party>/.
|
||||
# archive/<party>/{received,issued}/ the committed record. PURE
|
||||
# WORM (one rule on archive/, no
|
||||
# exceptions): write/delete
|
||||
# stripped for all; create only
|
||||
# for document_controller (the
|
||||
# worm: list); admins bypass.
|
||||
# Party record dirs appear on the
|
||||
# first filing.
|
||||
# incoming/<party>/ counterparty drop zone
|
||||
# reviewing/<party>/<tracking>/ we review their submission
|
||||
# working/<party>/ our drafts (edit-history on)
|
||||
# staging/<party>/<tracking>/ assemble transmittals
|
||||
# mdl/<party>/*.yaml master document list (tables)
|
||||
# rsk/<party>/*.yaml risk register (tables)
|
||||
# ssr/<party>.yaml submittal status register — AND
|
||||
# the AUTHORITATIVE PARTY REGISTRY
|
||||
#
|
||||
# Six top-level virtuals sit beside archive/ as resolver views:
|
||||
# Party registry: `ssr/<party>.yaml` existence is the SINGLE source of
|
||||
# truth for "party <party> exists". Creating it (rwc at ssr/, via the
|
||||
# SSR form) is how a party is born. Every OTHER peer carries
|
||||
# `party_source: ssr`, so you cannot create <peer>/<party>/… — archive
|
||||
# filing included — until the ssr row exists; the server 409s otherwise.
|
||||
# ssr/ itself has no party_source (it is the source).
|
||||
#
|
||||
# ssr mdl rsk tables rollups across parties
|
||||
# (with a synthesized $party column)
|
||||
# working staging browse folder-nav listings of
|
||||
# reviewing parties with non-empty content in
|
||||
# the slot (in-flight filter). The
|
||||
# virtual 302-redirects to the
|
||||
# canonical archive/<party>/<slot>/.
|
||||
# mdl/ and rsk/ AGGREGATE: the peer root renders ALL parties in one
|
||||
# table (a $party column derived from the real subdir), <peer>/<party>/
|
||||
# shows that party's rows. ssr/ aggregates naturally (one flat file per
|
||||
# party). $party is a real directory level, not a synthesized column.
|
||||
#
|
||||
# Mkdir at the project root is restricted to `archive` plus system
|
||||
# (_/.-prefixed) names; the six virtual aggregator names are rejected
|
||||
# because the virtual would shadow any physical folder created at
|
||||
# those URLs (see handler/fileapi.go).
|
||||
#
|
||||
# Everything below is expressed via the recursive paths: schema. None
|
||||
# of the directories need to exist on disk — the cascade walker
|
||||
# resolves behaviour from this declaration, so a fresh project lands
|
||||
# on usable empty views at every well-known URL.
|
||||
#
|
||||
# Operators override any of this by mirroring the structure in an
|
||||
# on-disk .zddc and changing what they need; on-disk values win.
|
||||
# Mkdir at the project root is restricted to the peer names above plus
|
||||
# system (_/.-prefixed) names (see handler/fileapi.go). Nothing here
|
||||
# needs to exist on disk — the cascade resolves behaviour so a fresh
|
||||
# project lands on usable empty views at every well-known URL. Operators
|
||||
# override by mirroring this structure in an on-disk .zddc.
|
||||
|
||||
paths:
|
||||
# First segment under root is the project name; "*" matches any.
|
||||
"*":
|
||||
# Project-scoped baseline ACL. project_team and observer get read
|
||||
# across the project; document_controller gets read + overwrite-
|
||||
# existing (so people can ask them to fix a stuck file). None of
|
||||
# the three gets `c` (create) at this level — that's granted only
|
||||
# at the specific spots below (archive/, working/, staging/), so
|
||||
# the doc controller can't make arbitrary folders. Grants here cap
|
||||
# at deeper levels per deepest-match-wins, except where a deeper
|
||||
# .zddc restates a fuller grant for the same principal.
|
||||
# Project-scoped baseline ACL. project_team and observer read across
|
||||
# the project; document_controller gets read + overwrite-existing.
|
||||
# None gets `c` here — create is granted only at the specific peers
|
||||
# below (archive/, ssr/, and the workspaces).
|
||||
acl:
|
||||
permissions:
|
||||
project_team: r
|
||||
observer: r
|
||||
document_controller: rw
|
||||
paths:
|
||||
# ── Top-level virtual aggregators ───────────────────────────
|
||||
#
|
||||
# Six resolver views, sibling to archive/. None of these
|
||||
# materialise on disk; the server synthesises listings by
|
||||
# walking archive/*/<slot>/ at request time and (for the
|
||||
# tables rollups) rewrites file reads/writes back to canonical
|
||||
# paths inside the per-party folders. ACL on each synthetic
|
||||
# row is evaluated against the canonical archive/<party>/
|
||||
# chain, so party owners can edit their own rows and non-
|
||||
# owners see them read-only.
|
||||
ssr:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
virtual: true
|
||||
mdl:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
virtual: true
|
||||
# Edit-history default-on for the deliverables list (subtree-
|
||||
# inheriting; see working/ note). Operators override per .zddc.
|
||||
history: true
|
||||
rsk:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
virtual: true
|
||||
# Edit-history default-on for the risk register.
|
||||
history: true
|
||||
working:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
# Pure folder-nav aggregator over the per-party
|
||||
# archive/<party>/working/ slots — same shape as staging/ and
|
||||
# reviewing/ below. Nothing lives directly at <project>/working/:
|
||||
# creating a folder here prompts for a party (browse's "New
|
||||
# folder" picker) and lands it at archive/<party>/working/<name>,
|
||||
# which carries its own history: true + auto-own convention.
|
||||
virtual: true
|
||||
# Edit-history default-on across the working subtree (markdown
|
||||
# saves are snapshotted to .history/<stem>/). Subtree-inheriting,
|
||||
# so it also covers any pre-reshape <project>/working/<…> homes
|
||||
# that still hold content. Reads of recorded history never require
|
||||
# this flag — turning it off only stops new snapshots.
|
||||
history: true
|
||||
staging:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
virtual: true
|
||||
reviewing:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
virtual: true
|
||||
|
||||
# ── Physical party root ─────────────────────────────────────
|
||||
# ── The committed record: pure WORM ─────────────────────────
|
||||
archive:
|
||||
default_tool: archive
|
||||
# The doc controller can create party subfolders here
|
||||
# (archive/<party>/). Restate the full grant — deepest-match
|
||||
# is per-principal replacement, so we re-list rw + add c.
|
||||
# A record can only be filed for a registered party.
|
||||
party_source: ssr
|
||||
# The ONE WORM rule. Cascades to <party>/{received,issued}:
|
||||
# write/delete stripped for everyone; create survives only for
|
||||
# document_controller; admins bypass (the escape hatch).
|
||||
worm: [document_controller]
|
||||
# rwc so a DC can create party record dirs (WORM masks w/d to
|
||||
# leave read + write-once-create).
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwc
|
||||
|
||||
# ── Authoritative party registry + submittal status register ─
|
||||
ssr:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
# NO party_source — ssr/ IS the source of party existence.
|
||||
# rwc: a DC registers a party by creating ssr/<party>.yaml and
|
||||
# maintains its status (overwrite). Delete (de-register) is left
|
||||
# to admins so a party with archived records is never orphaned.
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwc
|
||||
history: true
|
||||
records:
|
||||
"*.yaml":
|
||||
field_defaults:
|
||||
kind: SSR
|
||||
locked: [kind]
|
||||
|
||||
# ── Inbound workspace: counterparty drop zone ───────────────
|
||||
incoming:
|
||||
default_tool: classifier
|
||||
available_tools: [classifier]
|
||||
party_source: ssr
|
||||
# The other party's DC uploads here (a deployment grants them
|
||||
# cr, e.g. acl: { permissions: { "*@acme.com": cr } } at
|
||||
# incoming/Acme/.zddc); OUR DC QCs via classifier and moves to
|
||||
# archive/<party>/received. project_team has read only (observe).
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
paths:
|
||||
# Second segment under archive/ is the party name.
|
||||
"*":
|
||||
# When the doc controller creates a party folder, the
|
||||
# auto-own .zddc grants:
|
||||
# - the creator's email rwcda (the standard auto_own
|
||||
# mechanism)
|
||||
# - the document_controller role rwcda (auto_own_roles
|
||||
# below) so any DC in the role has full authority at
|
||||
# every party, not just the parties they personally
|
||||
# mkdir'd
|
||||
#
|
||||
# UNFENCED — so the project-level project_team:r still
|
||||
# cascades through to received/issued/incoming. That
|
||||
# lets the DC who created the party set up the counter-
|
||||
# party's own .zddc afterward (e.g. granting them cr at
|
||||
# incoming/).
|
||||
#
|
||||
# No `admins:` here by design. The DC role gets full
|
||||
# authority via the role grant in the auto-own .zddc, not
|
||||
# via subtree-admin status — so WORM masks at
|
||||
# received/issued still bind them (they file write-once
|
||||
# via the worm: list), and per-user fenced homes under
|
||||
# working/ stay private to their creators. Admin
|
||||
# elevation is reserved for the root admins list (the
|
||||
# actual sudo-style escape hatch).
|
||||
"*": # incoming/<party>
|
||||
auto_own: true
|
||||
auto_own_roles: [document_controller]
|
||||
# SSR record: the party folder's ssr.yaml carries this
|
||||
# party's vendor / contract / status data. Scoped by
|
||||
# filename pattern so the lock on `kind` only applies to
|
||||
# ssr.yaml — the mdl/, rsk/, received/, working/,
|
||||
# staging/, reviewing/ subfolders are untouched. No
|
||||
# filename_format because identity is the party folder
|
||||
# name, not a composed tracking number.
|
||||
drop_target: true
|
||||
|
||||
# ── Inbound workspace: review of their submission ───────────
|
||||
reviewing:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
party_source: ssr
|
||||
# The Plan-Review composite endpoint scaffolds a folder here per
|
||||
# submittal under review, with a .zddc carrying received_path
|
||||
# back to the canonical record in archive/<party>/received.
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
paths:
|
||||
"*": # reviewing/<party>
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
|
||||
# ── Outbound workspace: our drafts (edit-history on) ────────
|
||||
working:
|
||||
default_tool: browse
|
||||
available_tools: [browse, classifier]
|
||||
party_source: ssr
|
||||
# Subtree-inheriting: every markdown save under working/ is
|
||||
# snapshotted to .zddc.d/history/<stem>/ with a server-stamped
|
||||
# audit line. Reads of recorded history never require this flag.
|
||||
history: true
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
paths:
|
||||
"*": # working/<party> — auto-owned by its creator
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
|
||||
# ── Outbound workspace: assemble transmittals ───────────────
|
||||
staging:
|
||||
default_tool: transmittal
|
||||
available_tools: [transmittal, classifier]
|
||||
party_source: ssr
|
||||
# project_team drops files (cr); after the drop the doc-control
|
||||
# workflow owns it. DC gets rwcda — `d` for the cut to issued/,
|
||||
# `a` so Plan Review can write staging/<tracking>/.zddc.
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
paths:
|
||||
"*": # staging/<party>
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
|
||||
# ── Master document list (aggregates across parties) ────────
|
||||
mdl:
|
||||
default_tool: tables # peer root: all-parties table
|
||||
available_tools: [tables]
|
||||
party_source: ssr
|
||||
history: true
|
||||
# The DC maintains the deliverables register (create/edit/delete
|
||||
# rows). project_team reads it (inherited from the project level).
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
# field_codes: constrain tracking-number components here (or
|
||||
# higher in the cascade). Three kinds — enum / pattern / free;
|
||||
# map-merge across levels. originator is folder-bound (below),
|
||||
# so it is not listed here. Example:
|
||||
# field_codes:
|
||||
# discipline: { kind: enum, codes: { EL: Electrical, ME: Mechanical } }
|
||||
# sequence: { kind: pattern, pattern: "[0-9]{4}" }
|
||||
paths:
|
||||
"*": # mdl/<party>: that party's rows, flat
|
||||
default_tool: tables
|
||||
# MDL records: each .yaml is an independent deliverable with
|
||||
# its own composed tracking number. originator is the party
|
||||
# folder (the record's own dir, distance 0 above
|
||||
# mdl/<party>/<file>.yaml) and renders read-only — the folder
|
||||
# is the single source of truth for the originator code.
|
||||
#
|
||||
# To add project-wide components (phase, area, …), override
|
||||
# filename_format here AND mdl/<party>/{form,table}.yaml.
|
||||
records:
|
||||
"ssr.yaml":
|
||||
"*.yaml":
|
||||
folder_fields:
|
||||
originator: 0
|
||||
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}"
|
||||
|
||||
# ── Risk register (aggregates across parties) ───────────────
|
||||
rsk:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
party_source: ssr
|
||||
history: true
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
paths:
|
||||
"*": # rsk/<party>
|
||||
default_tool: tables
|
||||
# RSK records: each .yaml is a row of a parent rsk-type
|
||||
# deliverable; the server auto-assigns -{row} within the
|
||||
# row-scope group on POST-create. originator is folder-bound
|
||||
# to the party folder, same as MDL.
|
||||
records:
|
||||
"*.yaml":
|
||||
folder_fields:
|
||||
originator: 0
|
||||
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}-{row}"
|
||||
field_defaults:
|
||||
kind: SSR
|
||||
locked: [kind]
|
||||
# ── Field-code vocabularies (field_codes:) ──────────────
|
||||
# Each tracking-number component can be constrained by a
|
||||
# field_codes entry at this (per-party) level — or higher
|
||||
# if every party shares the same vocabulary. Three kinds:
|
||||
#
|
||||
# enum — closed code list; the label surfaces in form
|
||||
# dropdowns and is enforced on POST/PUT.
|
||||
# pattern — anchored regex (server wraps it with ^…$).
|
||||
# free — no constraint; `description:` is help-text in
|
||||
# the form UI.
|
||||
#
|
||||
# Map-merge across the cascade: a deeper .zddc can narrow
|
||||
# or replace a single code without re-listing the others.
|
||||
# `originator` is normally NOT listed here — it's bound to
|
||||
# the party-folder name via folder_fields on the mdl/ + rsk/
|
||||
# records below, so the folder is its sole source of truth.
|
||||
#
|
||||
# field_codes:
|
||||
# discipline:
|
||||
# kind: enum
|
||||
# codes:
|
||||
# EL: "Electrical"
|
||||
# ME: "Mechanical"
|
||||
# CV: "Civil"
|
||||
# sequence:
|
||||
# kind: pattern
|
||||
# pattern: "[0-9]{4}" # zero-padded 4-digit
|
||||
# type:
|
||||
# kind: free
|
||||
# description: "Document category code within the discipline"
|
||||
paths:
|
||||
mdl:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
# The mdl folder is virtual by convention — the
|
||||
# tables tool serves it from the embedded default
|
||||
# spec even when the on-disk folder doesn't exist.
|
||||
virtual: true
|
||||
# Edit-history default-on (markdown notes/specs saved here
|
||||
# are snapshotted; .yaml records keep their own record-
|
||||
# history path regardless).
|
||||
history: true
|
||||
# MDL records: each .yaml file is an independent
|
||||
# deliverable with its own composed tracking number.
|
||||
# No type lock — the row's body fields drive the
|
||||
# filename; type is free-choice from the deployment's
|
||||
# field_codes (see the field_codes block above).
|
||||
#
|
||||
# Default template — five required components plus an
|
||||
# optional per-deliverable suffix that marks parts of
|
||||
# the SAME deliverable (A = Appendix A, 01 = Sheet 1):
|
||||
#
|
||||
# originator-project-discipline-type-sequence[-suffix]
|
||||
#
|
||||
# `originator` is folder-bound: the server sets it from
|
||||
# the party-folder name (folder_fields below) and the
|
||||
# form renders it read-only — the party folder is the
|
||||
# single source of truth for the originator code.
|
||||
#
|
||||
# To add PROJECT-WIDE components (phase, area, ...),
|
||||
# override filename_format here AND add matching
|
||||
# properties to mdl/form.yaml + columns to mdl/table.yaml.
|
||||
# Pick once per project and apply to EVERY deliverable;
|
||||
# mixing schemas within one project breaks lexical sort
|
||||
# and filtering. Example:
|
||||
# records:
|
||||
# "*.yaml":
|
||||
# folder_fields: { originator: 1 }
|
||||
# filename_format: "{originator}-{phase}-{project}-{area}-{discipline}-{type}-{sequence}-{suffix?}"
|
||||
records:
|
||||
"*.yaml":
|
||||
folder_fields:
|
||||
originator: 1
|
||||
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}"
|
||||
rsk:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
# Risk register — same virtual-by-convention pattern
|
||||
# as mdl/. Embedded default-rsk spec backs it when no
|
||||
# operator override is on disk.
|
||||
virtual: true
|
||||
# Edit-history default-on (same as mdl/).
|
||||
history: true
|
||||
# RSK records: each .yaml file is a row of a parent
|
||||
# rsk-type deliverable. The table itself has a tracking
|
||||
# number (same default components as an MDL deliverable
|
||||
# with type=RSK); rows append a -{row} suffix the server
|
||||
# auto-assigns within the row-scope group on POST-create.
|
||||
# `originator` is folder-bound to the party folder, same
|
||||
# as MDL.
|
||||
#
|
||||
# To add project-wide phase / area components, override
|
||||
# BOTH filename_format AND row_scope_fields here — the
|
||||
# scope fields decide which rows share a row-number
|
||||
# sequence, so they must list the same components the
|
||||
# filename does.
|
||||
records:
|
||||
"*.yaml":
|
||||
folder_fields:
|
||||
originator: 1
|
||||
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}-{row}"
|
||||
field_defaults:
|
||||
type: RSK
|
||||
locked: [type]
|
||||
row_field: row
|
||||
row_scope_fields: [originator, project, discipline, type, sequence, suffix]
|
||||
incoming:
|
||||
# incoming/ is the COUNTERPARTY's drop zone. The flow:
|
||||
# 1. the other party's document controller uploads
|
||||
# files here (a deployment grants them cr at this
|
||||
# path, e.g. acl: { permissions: { "*@acme.com": cr } }
|
||||
# at archive/Acme/incoming/.zddc — or they mkdir a
|
||||
# dated subfolder under incoming/ and own it via
|
||||
# auto_own)
|
||||
# 2. OUR document controller QCs them via classifier
|
||||
# (rename in place) and moves them to received/
|
||||
# (which needs delete here + worm-create there),
|
||||
# ideally returning a signed transmittal in issued/
|
||||
#
|
||||
# The normal project_team has only read here (inherited
|
||||
# from the project level — they have no c/w) so they can
|
||||
# see what's been dropped but not touch it. The
|
||||
# document_controller grant restates rwcd so the QC +
|
||||
# transfer-out workflow has the delete bit it needs.
|
||||
default_tool: classifier
|
||||
available_tools: [classifier]
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
# received/ and issued/ are WORM (write-once-read-many).
|
||||
# The `worm:` list marks the zone:
|
||||
#
|
||||
# - write (w) and delete (d) are stripped for EVERYONE
|
||||
# - create (c) is stripped for everyone EXCEPT the
|
||||
# principals listed — they get read + write-once-
|
||||
# create ("cr")
|
||||
# - read for non-listed principals is whatever the
|
||||
# normal cascade ACL granted; the WORM list does not
|
||||
# itself confer read to outsiders
|
||||
# - admins (root / subtree) bypass entirely — the
|
||||
# human escape hatch for mis-filed documents
|
||||
#
|
||||
# The baseline is an empty list: WORM zone, no
|
||||
# create-capable principals — filing is locked until a
|
||||
# deployment names a document controller, e.g.
|
||||
#
|
||||
# worm: ["doc-control@example.com"]
|
||||
#
|
||||
# at received/ (or issued/, or archive/<party>/, or
|
||||
# wherever scopes it right). worm: lists UNION across the
|
||||
# cascade, so a deeper .zddc adds more controllers.
|
||||
received:
|
||||
default_tool: archive
|
||||
# document_controller may file write-once into the
|
||||
# WORM zone. Their project-level rw is masked here
|
||||
# to r; worm: restores write-once-create.
|
||||
worm: [document_controller]
|
||||
issued:
|
||||
default_tool: archive
|
||||
worm: [document_controller]
|
||||
# ── In-flight lifecycle slots (NEW — nested per-party) ────
|
||||
#
|
||||
# working/staging/reviewing now live inside each party
|
||||
# folder instead of at the project root. The project-
|
||||
# level <project>/{working,staging,reviewing} virtuals
|
||||
# (declared above) are folder-nav views over these
|
||||
# canonical per-party slots.
|
||||
# ── In-flight ratchet ───────────────────────────────
|
||||
#
|
||||
# The lifecycle slots form a one-way handoff:
|
||||
#
|
||||
# working/ → staging/ → issued/ (WORM)
|
||||
# (full) (cr) (worm cr)
|
||||
#
|
||||
# At each step the previous role's modify rights drop:
|
||||
# project_team iterates freely in working/; when they
|
||||
# promote to staging/ they can't change it without doc-
|
||||
# controller help; when DC publishes to issued/ even
|
||||
# they can't change it without elevation. Each ACL
|
||||
# grant below is the verb-set the ROLE keeps at that
|
||||
# step; auto_own + auto_own_fenced sub-folder grants
|
||||
# layer per-creator ownership on top of these.
|
||||
working:
|
||||
default_tool: browse
|
||||
available_tools: [browse, classifier]
|
||||
# Project_team gets read + create here so they can
|
||||
# mkdir their own home folder (and any shared sub-
|
||||
# folders). The auto_own_fenced declaration at the
|
||||
# `*` child below makes the new folder a private home
|
||||
# with rwcda for the creator (fenced from ancestors,
|
||||
# so collaborators only join after the owner edits
|
||||
# the home's .zddc to grant them access).
|
||||
#
|
||||
# `cr` instead of just `c` so an existing file at
|
||||
# working/ root stays readable to all team members
|
||||
# (cascade is per-level deepest-match — a single `c`
|
||||
# would shadow the project-level `r`).
|
||||
#
|
||||
# `document_controller: rwcda` is restated here so a
|
||||
# DC whose email is ALSO matched by project_team
|
||||
# (typical when project_team is `*@example.com`) gets
|
||||
# the higher grant via within-level union. Without
|
||||
# the restatement, the cascade's deepest-level-wins
|
||||
# would pick project_team's cr and shadow the DC's
|
||||
# rwcda inherited from the party's auto-own .zddc.
|
||||
# Same pattern applied at staging/ and reviewing/.
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
# working/ auto-owns the first creator + the per-user
|
||||
# homes below.
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
# Edit-history: every markdown save under working/ (incl.
|
||||
# the fenced per-user homes — history inherits through
|
||||
# fences) is versioned into a sibling .history/ store with
|
||||
# a server-stamped audit line (who + when). The live file
|
||||
# stays the source of truth; GET <file>?history lists prior
|
||||
# versions. See ZddcFile.History / handler.WriteTextWithHistory.
|
||||
history: true
|
||||
paths:
|
||||
"*": # per-user home dir, fenced
|
||||
default_tool: browse
|
||||
available_tools: [browse, classifier]
|
||||
auto_own: true
|
||||
# Per-user home is private by default: the generated
|
||||
# auto-own .zddc carries inherit:false so ancestor ACL
|
||||
# grants don't reach inside. The user can edit the file
|
||||
# to grant collaborators access.
|
||||
auto_own_fenced: true
|
||||
drop_target: true
|
||||
staging:
|
||||
default_tool: transmittal
|
||||
available_tools: [transmittal, classifier]
|
||||
# The ratchet step from working/. project_team gets
|
||||
# `cr` — they can drop files (PUT new files at
|
||||
# staging/) and read what's there, but cannot edit or
|
||||
# delete after the drop. Once a file is in staging it
|
||||
# belongs to the doc-controller workflow; the team
|
||||
# member needs to ask DC to change it.
|
||||
#
|
||||
# Convention: project_team drops FILES at staging/,
|
||||
# not sub-folders. A sub-folder mkdir'd by project_
|
||||
# team would trigger auto_own and grant them rwcda
|
||||
# inside their own sub-folder (auto_own is path-keyed,
|
||||
# not role-keyed — it fires for any creator). The
|
||||
# auto_own here is preserved for DC's per-transmittal
|
||||
# mkdir flow; project_team can keep to file drops to
|
||||
# honour the "can't alter after" intent.
|
||||
#
|
||||
# DC gets rwcda explicitly — `d` for the cut to issued/,
|
||||
# `a` so Plan Review can write the staging/<tracking>/.zddc
|
||||
# the composite endpoint scaffolds. Restated here (not
|
||||
# inherited from the party-level role grant) so the
|
||||
# within-level union dominates project_team's cr for
|
||||
# any DC matched by the team wildcard.
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
reviewing:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
# reviewing/ is the doc-controller's draft-workspace
|
||||
# area inside this party folder. The "Plan Review"
|
||||
# composite endpoint scaffolds a physical folder here
|
||||
# for each submittal under review, with a .zddc
|
||||
# carrying received_path back to the canonical
|
||||
# submittal in received/. Subtree-admin (inherited
|
||||
# from the party-level admins:) so the doc
|
||||
# controller can author per-folder .zddc files
|
||||
# (originator ACL, planned_date).
|
||||
#
|
||||
# project_team gets `cr` so the originating team can
|
||||
# create review-iteration folders alongside the
|
||||
# Plan-Review-scaffolded ones. auto_own (unfenced
|
||||
# here, unlike working/) gives the creator rwcda
|
||||
# inside; siblings see the iteration via the project-
|
||||
# level project_team:r cascade.
|
||||
#
|
||||
# document_controller: rwcda restated for the same
|
||||
# reason as working/ + staging/ — keeps a DC matched
|
||||
# by the project_team wildcard at full authority via
|
||||
# within-level union.
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
type: RSK
|
||||
locked: [type]
|
||||
row_field: row
|
||||
row_scope_fields: [originator, project, discipline, type, sequence, suffix]
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
|
|||
|
||||
if len(parts) >= 2 {
|
||||
seg := strings.ToLower(parts[1])
|
||||
if seg == "archive" {
|
||||
if IsProjectPeer(seg) {
|
||||
if err := resolveAt(1, seg); err != nil {
|
||||
return target, err
|
||||
}
|
||||
|
|
@ -70,10 +70,10 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
|
|||
|
||||
// EnsureCanonicalAncestors walks from fsRoot down to filepath.Dir(target),
|
||||
// creating any missing canonical-folder ancestor with MkdirAll(perm).
|
||||
// For freshly-created auto-own ancestors (archive/<party>/, and the per-
|
||||
// party lifecycle slots {working,staging,reviewing,incoming}), it also
|
||||
// writes a creator-owned .zddc using principalEmail (skipped if
|
||||
// principalEmail is empty).
|
||||
// For freshly-created auto-own ancestors (the workspace party folders),
|
||||
// it also writes a creator-owned .zddc using principalEmail (skipped if
|
||||
// principalEmail is empty) — auto-own + fence are resolved per-dir via
|
||||
// the .zddc cascade (AutoOwnAt / AutoOwnFencedAt).
|
||||
//
|
||||
// Returns the resolved version of target with on-disk casing substituted
|
||||
// for any canonical ancestor whose disk variant differs from the requested
|
||||
|
|
@ -82,14 +82,12 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
|
|||
//
|
||||
// Canonical positions, relative to fsRoot:
|
||||
//
|
||||
// - <project>/archive (the only physical project-root canonical;
|
||||
// working/staging/reviewing/ssr/mdl/rsk at project root are virtual
|
||||
// aggregators with no on-disk presence — writes targeting them
|
||||
// must be rejected by the caller's project-root mkdir guard.)
|
||||
// - <project>/<peer> for any top-level peer (IsProjectPeer: archive,
|
||||
// incoming, working, staging, reviewing, mdl, rsk, ssr) — all are
|
||||
// physical directories.
|
||||
//
|
||||
// - <project>/archive/<party>/<canonical-party> where
|
||||
// <canonical-party> ∈ {mdl, rsk, incoming, received, issued,
|
||||
// working, staging, reviewing}
|
||||
// - <project>/archive/<party>/<slot> where <slot> ∈ {received, issued}
|
||||
// (IsPerPartySlot) — the WORM record folders.
|
||||
//
|
||||
// fsRoot and target must be absolute filesystem paths under the same
|
||||
// volume; target may not yet exist on disk.
|
||||
|
|
@ -109,16 +107,6 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
|||
return target, nil
|
||||
}
|
||||
|
||||
// Reject writes targeting top-level virtual aggregators —
|
||||
// <project>/{ssr,mdl,rsk,working,staging,reviewing}/... — these
|
||||
// resolve through ResolveVirtualView, not as physical paths. A
|
||||
// caller writing under them bypassed the virtual resolver; the
|
||||
// content belongs under archive/<party>/<slot>/ (browse's "New
|
||||
// folder" picker prompts for the party).
|
||||
if len(parts) >= 2 && IsVirtualAggregatorSlot(strings.ToLower(parts[1])) {
|
||||
return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1])
|
||||
}
|
||||
|
||||
resolvedSegs := make([]string, len(parts))
|
||||
copy(resolvedSegs, parts)
|
||||
|
||||
|
|
@ -161,18 +149,17 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
|||
// Walk depth 1 (project) → deeper levels, resolving + tracking as we go.
|
||||
// Depth 0 is the project segment; not a canonical name.
|
||||
if len(parts) >= 2 {
|
||||
// Depth 1 candidate: archive (only physical project-root canonical).
|
||||
// Depth 1 candidate: any top-level peer (all physical now).
|
||||
seg := strings.ToLower(parts[1])
|
||||
if seg == "archive" {
|
||||
if IsProjectPeer(seg) {
|
||||
if err := resolveAt(1, seg); err != nil {
|
||||
return target, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Depth 3 candidate (archive/<party>/<canonical-party>): the eight
|
||||
// physical per-party slots. Only meaningful when depth 1 is
|
||||
// "archive".
|
||||
// Depth 3 candidate (archive/<party>/<slot>): the WORM record slots
|
||||
// received/issued. Only meaningful when depth 1 is "archive".
|
||||
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
||||
seg := strings.ToLower(parts[3])
|
||||
if IsPerPartySlot(seg) {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,10 @@ import (
|
|||
|
||||
func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Per-user homes now live under archive/<party>/working/<email>/
|
||||
// after the top-of-project reshape. The depth-3 working slot is
|
||||
// the canonical-folder position; its auto-own .zddc is unfenced
|
||||
// and the depth-4 per-user home gets the fenced one.
|
||||
target := filepath.Join(root, "Proj", "archive", "ACME", "working", "alice@x.com", "notes.md")
|
||||
// working/ is a top-level peer; its <party> folder auto-owns the
|
||||
// creator (unfenced — party admins still cascade in). Per-user email
|
||||
// homes were abandoned in the reshape.
|
||||
target := filepath.Join(root, "Proj", "working", "ACME", "notes.md")
|
||||
|
||||
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
||||
if err != nil {
|
||||
|
|
@ -23,13 +22,10 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
|
|||
t.Errorf("resolved=%q, target=%q (no case variant exists, should be identical)", resolved, target)
|
||||
}
|
||||
|
||||
// working/ is now created with auto-own .zddc (unfenced — party
|
||||
// admins still cascade through, only the per-user home below is
|
||||
// fenced).
|
||||
autoZ := filepath.Join(root, "Proj", "archive", "ACME", "working", ".zddc")
|
||||
autoZ := filepath.Join(root, "Proj", "working", "ACME", ".zddc")
|
||||
data, err := os.ReadFile(autoZ)
|
||||
if err != nil {
|
||||
t.Fatalf("auto-own .zddc not written at working/: %v", err)
|
||||
t.Fatalf("auto-own .zddc not written at working/ACME/: %v", err)
|
||||
}
|
||||
body := string(data)
|
||||
if !strings.Contains(body, "alice@x.com: rwcda") {
|
||||
|
|
@ -39,127 +35,74 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
|
|||
t.Errorf("created_by missing: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "inherit: false") {
|
||||
t.Errorf("party working/ .zddc should be UNFENCED so party admins still reach inside; got: %s", body)
|
||||
t.Errorf("working/<party>/ .zddc should be UNFENCED; got: %s", body)
|
||||
}
|
||||
|
||||
// alice@x.com/ subfolder gets a FENCED auto-own .zddc — private by
|
||||
// default so other users can't read alice's drafts via ancestor
|
||||
// cascade. alice can edit the file later to add collaborators.
|
||||
homeZddc := filepath.Join(root, "Proj", "archive", "ACME", "working", "alice@x.com", ".zddc")
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working", "alice@x.com")); err != nil {
|
||||
t.Errorf("subfolder not created: %v", err)
|
||||
}
|
||||
homeData, err := os.ReadFile(homeZddc)
|
||||
if err != nil {
|
||||
t.Fatalf("per-user-home auto-own .zddc not written: %v", err)
|
||||
}
|
||||
homeBody := string(homeData)
|
||||
if !strings.Contains(homeBody, "alice@x.com: rwcda") {
|
||||
t.Errorf("per-user-home grant missing: %s", homeBody)
|
||||
}
|
||||
if !strings.Contains(homeBody, "inherit: false") {
|
||||
t.Errorf("per-user-home .zddc should have inherit: false; got: %s", homeBody)
|
||||
// The working/ peer root itself does NOT auto-own (auto_own is at the
|
||||
// <party> level).
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) {
|
||||
t.Errorf("working/ peer root should not have auto-own .zddc; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// staging/<folder>/ is NOT fenced — staging is a shared lane (transmittal
|
||||
// folders are date+tracking-named, not per-user). Only per-user homes
|
||||
// under working/ get the fence.
|
||||
// staging/<party>/<folder>/ is NOT auto-owned — only the <party> level is.
|
||||
func TestEnsureCanonicalAncestors_StagingChildNotFenced(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "Proj", "archive", "ACME", "staging",
|
||||
target := filepath.Join(root, "Proj", "staging", "ACME",
|
||||
"2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", "doc.pdf")
|
||||
if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
// staging/<folder>/.zddc should not exist (only the parent staging/
|
||||
// gets an auto-own; the date-named child is plain).
|
||||
childZddc := filepath.Join(root, "Proj", "archive", "ACME", "staging",
|
||||
childZddc := filepath.Join(root, "Proj", "staging", "ACME",
|
||||
"2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", ".zddc")
|
||||
if _, err := os.Stat(childZddc); !os.IsNotExist(err) {
|
||||
t.Errorf("staging child should NOT have auto-own .zddc; got err=%v", err)
|
||||
}
|
||||
// And the staging/ slot itself gets the unfenced auto-own.
|
||||
stagingZddc := filepath.Join(root, "Proj", "archive", "ACME", "staging", ".zddc")
|
||||
stagingZddc := filepath.Join(root, "Proj", "staging", "ACME", ".zddc")
|
||||
if _, err := os.Stat(stagingZddc); err != nil {
|
||||
t.Errorf("party staging/ auto-own .zddc missing: %v", err)
|
||||
t.Errorf("staging/<party> auto-own .zddc missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCanonicalAncestors_CaseFoldReuse(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Pre-create Archive/ (PascalCase) — case-fold reuse applies to
|
||||
// the canonical project-root slot.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "Archive", "ACME", "working"), 0o755); err != nil {
|
||||
// Pre-create Archive/ (PascalCase) — case-fold reuse applies to the
|
||||
// canonical project-root peer + the received/issued slots.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "Archive", "ACME", "received"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
target := filepath.Join(root, "Proj", "archive", "ACME", "working", "foo.md")
|
||||
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
||||
target := filepath.Join(root, "Proj", "archive", "ACME", "received", "foo.pdf")
|
||||
resolved, err := EnsureCanonicalAncestors(root, target, "dc@x.com", 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
|
||||
// Resolved path uses on-disk Archive/ casing.
|
||||
want := filepath.Join(root, "Proj", "Archive", "ACME", "working", "foo.md")
|
||||
want := filepath.Join(root, "Proj", "Archive", "ACME", "received", "foo.pdf")
|
||||
if resolved != want {
|
||||
t.Errorf("resolved=%q, want %q", resolved, want)
|
||||
}
|
||||
|
||||
// No new lowercase archive/ sibling.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive")); !os.IsNotExist(err) {
|
||||
t.Errorf("lowercase sibling should not exist; got err=%v", err)
|
||||
}
|
||||
|
||||
// Archive/ already existed — no auto-own .zddc was retroactively written.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "Archive", ".zddc")); !os.IsNotExist(err) {
|
||||
t.Errorf("auto-own .zddc should not be written to a pre-existing folder; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCanonicalAncestors_PerPartyIncoming(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "Proj", "archive", "ACME", "incoming", "submission.pdf")
|
||||
// incoming/ is a top-level peer; its <party> folder auto-owns.
|
||||
target := filepath.Join(root, "Proj", "incoming", "ACME", "submission.pdf")
|
||||
|
||||
_, err := EnsureCanonicalAncestors(root, target, "rep@acme.com", 0o755)
|
||||
if err != nil {
|
||||
if _, err := EnsureCanonicalAncestors(root, target, "rep@acme.com", 0o755); err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
|
||||
// archive/ created (no auto-own — archive/ itself is a plain
|
||||
// container; the cascade declares no auto_own there).
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive")); err != nil {
|
||||
t.Errorf("archive/ not created: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", ".zddc")); !os.IsNotExist(err) {
|
||||
t.Errorf("archive/ should not have auto-own .zddc; got err=%v", err)
|
||||
}
|
||||
|
||||
// archive/ACME/ created WITH auto-own (the cascade declares
|
||||
// auto_own on the party-folder level so whoever creates a party
|
||||
// subtree owns it — used by the document controller to set up a
|
||||
// new counterparty's .zddc). Unfenced, so ancestor grants still
|
||||
// reach inside (project_team:r through to received/issued).
|
||||
partyZ := filepath.Join(root, "Proj", "archive", "ACME", ".zddc")
|
||||
pdata, err := os.ReadFile(partyZ)
|
||||
if err != nil {
|
||||
t.Fatalf("auto-own .zddc at ACME/ not written: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(pdata), "rep@acme.com: rwcda") {
|
||||
t.Errorf("ACME/ auto-own missing rep grant: %s", pdata)
|
||||
}
|
||||
if strings.Contains(string(pdata), "inherit: false") {
|
||||
t.Errorf("ACME/ auto-own should be UNFENCED; got: %s", pdata)
|
||||
}
|
||||
|
||||
// archive/ACME/incoming/ created WITH auto-own.
|
||||
autoZ := filepath.Join(root, "Proj", "archive", "ACME", "incoming", ".zddc")
|
||||
autoZ := filepath.Join(root, "Proj", "incoming", "ACME", ".zddc")
|
||||
data, err := os.ReadFile(autoZ)
|
||||
if err != nil {
|
||||
t.Fatalf("auto-own .zddc at incoming/ not written: %v", err)
|
||||
t.Fatalf("auto-own .zddc at incoming/ACME/ not written: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "rep@acme.com: rwcda") {
|
||||
t.Errorf("incoming/ auto-own missing rep grant: %s", data)
|
||||
t.Errorf("incoming/ACME auto-own missing rep grant: %s", data)
|
||||
}
|
||||
if strings.Contains(string(data), "inherit: false") {
|
||||
t.Errorf("incoming/ACME auto-own should be UNFENCED; got: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,49 +110,47 @@ func TestEnsureCanonicalAncestors_WormFoldersNoAutoOwn(t *testing.T) {
|
|||
root := t.TempDir()
|
||||
target := filepath.Join(root, "Proj", "archive", "ACME", "issued", "spec.pdf")
|
||||
|
||||
_, err := EnsureCanonicalAncestors(root, target, "dc@mycompany.com", 0o755)
|
||||
if err != nil {
|
||||
if _, err := EnsureCanonicalAncestors(root, target, "dc@mycompany.com", 0o755); err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "issued")); err != nil {
|
||||
t.Errorf("issued/ not created: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "issued", ".zddc")); !os.IsNotExist(err) {
|
||||
t.Errorf("issued/ should NOT have auto-own .zddc (WORM); got err=%v", err)
|
||||
}
|
||||
// archive/ and archive/<party>/ are plain containers — no auto-own.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", ".zddc")); !os.IsNotExist(err) {
|
||||
t.Errorf("archive/ should not have auto-own .zddc; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "Proj", "archive", "ACME", "working", "anon.md")
|
||||
target := filepath.Join(root, "Proj", "working", "ACME", "anon.md")
|
||||
|
||||
_, err := EnsureCanonicalAncestors(root, target, "" /* no email */, 0o755)
|
||||
if err != nil {
|
||||
if _, err := EnsureCanonicalAncestors(root, target, "" /* no email */, 0o755); err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working")); err != nil {
|
||||
t.Errorf("working/ not created: %v", err)
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "ACME")); err != nil {
|
||||
t.Errorf("working/ACME not created: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working", ".zddc")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "ACME", ".zddc")); !os.IsNotExist(err) {
|
||||
t.Errorf("auto-own .zddc must not be written without principalEmail; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Project-root virtual aggregator names are rejected — a write
|
||||
// targeting <project>/working/<...> bypasses the virtual resolver
|
||||
// and must not materialise on disk.
|
||||
func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) {
|
||||
// Top-level peers are physical now — a write under <project>/<peer>/<party>/
|
||||
// is created normally (no virtual-aggregator rejection).
|
||||
func TestEnsureCanonicalAncestors_TopLevelPeersCreated(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} {
|
||||
target := filepath.Join(root, "Proj", slot, "x.md")
|
||||
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
||||
if err == nil {
|
||||
t.Errorf("%s: expected error for write under <project>/%s/, got nil", slot, slot)
|
||||
for _, peer := range []string{"working", "staging", "reviewing", "incoming", "mdl", "rsk", "ssr"} {
|
||||
target := filepath.Join(root, "Proj", peer, "ACME", "x.md")
|
||||
if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil {
|
||||
t.Errorf("%s: unexpected error: %v", peer, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", slot)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s: <project>/%s/ must NOT be created on disk; got err=%v", slot, slot, err)
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj", peer, "ACME")); err != nil {
|
||||
t.Errorf("%s: <project>/%s/ACME/ should be created; got err=%v", peer, peer, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -218,8 +159,7 @@ func TestEnsureCanonicalAncestors_RejectsTraversal(t *testing.T) {
|
|||
root := t.TempDir()
|
||||
other := t.TempDir()
|
||||
target := filepath.Join(other, "evil.md")
|
||||
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
||||
if err == nil {
|
||||
if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err == nil {
|
||||
t.Errorf("expected error for target outside fsRoot")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,6 +263,17 @@ type ZddcFile struct {
|
|||
// not its descendants. Defaults (nil): no drop zone.
|
||||
DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"`
|
||||
|
||||
// PartySource names the registry that gates party-folder creation
|
||||
// under THIS peer. When set (e.g. "ssr"), a new <party> segment may
|
||||
// be created here only if the party is registered — i.e. the registry
|
||||
// entry <project>/<party_source>/<party>.yaml (or .../<party>/) exists
|
||||
// — otherwise the server 409s. The authoritative party registry is
|
||||
// ssr/ (a party exists iff ssr/<party>.yaml exists); ssr/ itself sets
|
||||
// no party_source. Leaf-only — the property describes THIS peer dir,
|
||||
// checked via PartyRegistered at party-folder-creating entrypoints.
|
||||
// Empty: no party gating.
|
||||
PartySource string `yaml:"party_source,omitempty" json:"party_source,omitempty"`
|
||||
|
||||
// History enables server-managed edit-history versioning for text
|
||||
// (markdown) writes in this subtree. When true, each save of a
|
||||
// history-eligible file (see handler.IsTextHistoryCandidate) snapshots
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package zddc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
|
@ -128,6 +129,45 @@ func DropTargetAt(fsRoot, dirPath string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// PartySourceAt returns the registry name that gates party-folder
|
||||
// creation under THIS peer directory (e.g. "ssr"), or "" if this peer
|
||||
// does no party gating. Leaf-only — the property describes the peer dir
|
||||
// (working/, archive/, …), not its party-folder children.
|
||||
func PartySourceAt(fsRoot, dirPath string) string {
|
||||
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
leaf := leafLevel(chain)
|
||||
if leaf.PartySource != "" {
|
||||
return leaf.PartySource
|
||||
}
|
||||
return chain.Embedded.PartySource
|
||||
}
|
||||
|
||||
// PartyRegistered reports whether party is registered in the named
|
||||
// registry under projectAbs (e.g. source="ssr" → the registry is
|
||||
// <projectAbs>/ssr/). A party exists iff its registry entry exists,
|
||||
// checked as either a flat row file <registry>/<party>.yaml or a folder
|
||||
// <registry>/<party>/ (so the key works for flat-file and folder-style
|
||||
// registers). An empty source means "no gating" and returns true.
|
||||
func PartyRegistered(projectAbs, source, party string) bool {
|
||||
if source == "" {
|
||||
return true
|
||||
}
|
||||
if party == "" {
|
||||
return false
|
||||
}
|
||||
reg := filepath.Join(projectAbs, source)
|
||||
if fi, err := os.Stat(filepath.Join(reg, party+".yaml")); err == nil && fi.Mode().IsRegular() {
|
||||
return true
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(reg, party)); err == nil && fi.IsDir() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// VirtualAt reports whether THIS specific directory is declared as
|
||||
// purely virtual (never materialise on disk). Leaf-only: the virtual
|
||||
// property describes a particular path, not a subtree. A child of a
|
||||
|
|
@ -264,36 +304,37 @@ func ChildrenDeclaredAt(fsRoot, dirPath string) []string {
|
|||
}
|
||||
|
||||
// CanonicalFolderAt returns the canonical-folder name for THIS specific
|
||||
// directory — one of "archive", "working", "staging", "reviewing",
|
||||
// "incoming", "received", "issued", "mdl", "rsk" — or "" if the path
|
||||
// is not at a canonical-folder slot.
|
||||
// directory — one of the top-level peers (archive, incoming, working,
|
||||
// staging, reviewing, mdl, rsk, ssr) or the WORM record slots
|
||||
// (received, issued) — or "" if the path is not at a canonical slot.
|
||||
//
|
||||
// Detection is structural against the canonical project layout declared
|
||||
// in defaults.zddc.yaml:
|
||||
// Detection is structural against the flat-peer layout declared in
|
||||
// defaults.zddc.yaml:
|
||||
//
|
||||
// - top-level <project>/archive is the only physical project-root
|
||||
// canonical slot (the working/staging/reviewing/ssr/mdl/rsk URLs
|
||||
// at project root are virtual aggregators, not on-disk folders).
|
||||
// - third-level archive/<party>/{mdl,rsk,incoming,received,issued,
|
||||
// working,staging,reviewing} are the physical per-party canonical
|
||||
// slots.
|
||||
// - second-level <project>/<peer> for any top-level peer.
|
||||
// - third-level <project>/<peer>/<party> reports its peer (slot) for
|
||||
// the workspace/register peers (not archive), so the SPA can scope
|
||||
// party-level context-menu actions.
|
||||
// - fourth-level <project>/archive/<party>/{received,issued} are the
|
||||
// WORM record slots.
|
||||
//
|
||||
// Operators don't rename these slots (the cascade keys them by
|
||||
// literal name); a custom layout that does is on its own.
|
||||
//
|
||||
// Used by the browse SPA to scope-gate context-menu actions (Accept,
|
||||
// Stage/Unstage, Create Transmittal folder) without re-implementing the
|
||||
// cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder header.
|
||||
// Operators don't rename these slots (the cascade keys them by literal
|
||||
// name). Used by the browse SPA to scope-gate context-menu actions
|
||||
// (Accept, Stage/Unstage, Create Transmittal) without re-implementing
|
||||
// the cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder
|
||||
// header.
|
||||
func CanonicalFolderAt(fsRoot, dirPath string) string {
|
||||
segs := resolvePathSegments(fsRoot, dirPath)
|
||||
// <project>/<folder> — only archive/ is physical at project root.
|
||||
if len(segs) == 2 {
|
||||
if segs[1] == "archive" {
|
||||
return "archive"
|
||||
}
|
||||
return ""
|
||||
// <project>/<peer> — all top-level peers are physical canonical slots.
|
||||
if len(segs) == 2 && IsProjectPeer(segs[1]) {
|
||||
return segs[1]
|
||||
}
|
||||
// <project>/archive/<party>/<folder>
|
||||
// <project>/<peer>/<party> — the party folder under a workspace/
|
||||
// register peer reports its peer; archive's party folder is not a slot.
|
||||
if len(segs) == 3 && IsProjectPeer(segs[1]) && segs[1] != "archive" {
|
||||
return segs[1]
|
||||
}
|
||||
// <project>/archive/<party>/<slot> — the WORM record slots.
|
||||
if len(segs) == 4 && segs[1] == "archive" && IsPerPartySlot(segs[3]) {
|
||||
return segs[3]
|
||||
}
|
||||
|
|
@ -324,6 +365,9 @@ func isZeroZddcFile(zf ZddcFile) bool {
|
|||
zf.DropTarget != nil || zf.Inherit != nil {
|
||||
return false
|
||||
}
|
||||
if zf.PartySource != "" {
|
||||
return false
|
||||
}
|
||||
if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" ||
|
||||
zf.PlannedResponseDate != "" {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -6,81 +6,69 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
// TestDefaultToolAt_FromEmbeddedConvention — the canonical default-
|
||||
// tool rules in defaults.zddc.yaml should resolve correctly for the
|
||||
// well-known paths without any on-disk .zddc.
|
||||
//
|
||||
// Layout reshape: lifecycle slots (working/staging/reviewing) now
|
||||
// live under archive/<party>/. The project-level
|
||||
// <project>/{working,staging,reviewing} URLs are virtual folder-nav
|
||||
// aggregators (default_tool=browse).
|
||||
// TestDefaultToolAt_FromEmbeddedConvention — the canonical default-tool
|
||||
// rules in defaults.zddc.yaml resolve correctly for the flat top-level
|
||||
// peers (and their per-party subdirs) without any on-disk .zddc.
|
||||
func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) }
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{filepath.Join(root, "Project-X", "archive"), "archive"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "tables"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), "tables"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "classifier"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "archive"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), "browse"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), "browse"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), "transmittal"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), "browse"},
|
||||
// Project-level virtual aggregators.
|
||||
{filepath.Join(root, "Project-X", "ssr"), "tables"},
|
||||
{filepath.Join(root, "Project-X", "mdl"), "tables"},
|
||||
{filepath.Join(root, "Project-X", "rsk"), "tables"},
|
||||
{filepath.Join(root, "Project-X", "working"), "browse"},
|
||||
{filepath.Join(root, "Project-X", "staging"), "browse"},
|
||||
{filepath.Join(root, "Project-X", "reviewing"), "browse"},
|
||||
// The committed record (archive subtree → archive tool).
|
||||
{j("archive"), "archive"},
|
||||
{j("archive", "Acme"), "archive"},
|
||||
{j("archive", "Acme", "received"), "archive"},
|
||||
{j("archive", "Acme", "issued"), "archive"},
|
||||
// Top-level peers.
|
||||
{j("ssr"), "tables"},
|
||||
{j("mdl"), "tables"},
|
||||
{j("rsk"), "tables"},
|
||||
{j("incoming"), "classifier"},
|
||||
{j("working"), "browse"},
|
||||
{j("staging"), "transmittal"},
|
||||
{j("reviewing"), "browse"},
|
||||
// Per-party subdirs inherit the peer's default_tool.
|
||||
{j("mdl", "Acme"), "tables"},
|
||||
{j("rsk", "Acme"), "tables"},
|
||||
{j("incoming", "Acme"), "classifier"},
|
||||
{j("working", "Acme"), "browse"},
|
||||
{j("staging", "Acme"), "transmittal"},
|
||||
{j("reviewing", "Acme"), "browse"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := DefaultToolAt(root, tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("DefaultToolAt(%q) = %q, want %q",
|
||||
tc.path[len(root):], got, tc.want)
|
||||
if got := DefaultToolAt(root, tc.path); got != tc.want {
|
||||
t.Errorf("DefaultToolAt(%q) = %q, want %q", tc.path[len(root):], got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryAt_Defaults — the embedded convention enables edit-history
|
||||
// versioning on the per-party archive/<party>/working/ subtree (+ its
|
||||
// homes). History is subtree-inheriting and ignores the homes'
|
||||
// inherit:false fences. The project-level working/ aggregator is virtual
|
||||
// (no direct content → no history), and sibling slots (staging,
|
||||
// reviewing, mdl, incoming, received) do NOT get history.
|
||||
// TestHistoryAt_Defaults — edit-history defaults on for the live-editing
|
||||
// peers working/mdl/rsk and the ssr registry (subtree-inheriting). The
|
||||
// other peers and the WORM archive do not get history.
|
||||
func TestHistoryAt_Defaults(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) }
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
// Edit-history defaults on for the three live-editing slots:
|
||||
// working, mdl, rsk — at both the project-level virtual nodes and
|
||||
// the per-party folders (subtree-inheriting).
|
||||
{filepath.Join(root, "Project-X", "working"), true},
|
||||
{filepath.Join(root, "Project-X", "working", "alice@example.com"), true},
|
||||
{filepath.Join(root, "Project-X", "mdl"), true},
|
||||
{filepath.Join(root, "Project-X", "rsk"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), true},
|
||||
// Other slots get no history.
|
||||
{filepath.Join(root, "Project-X", "staging"), false},
|
||||
{filepath.Join(root, "Project-X", "reviewing"), false},
|
||||
{filepath.Join(root, "Project-X", "ssr"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
||||
{filepath.Join(root, "Project-X", "archive"), false},
|
||||
{j("working"), true},
|
||||
{j("working", "Acme"), true},
|
||||
{j("working", "Acme", "notes"), true},
|
||||
{j("mdl"), true},
|
||||
{j("mdl", "Acme"), true},
|
||||
{j("rsk"), true},
|
||||
{j("ssr"), true},
|
||||
// No history elsewhere.
|
||||
{j("staging"), false},
|
||||
{j("reviewing"), false},
|
||||
{j("incoming"), false},
|
||||
{j("archive"), false},
|
||||
{j("archive", "Acme", "received"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := HistoryAt(root, tc.path); got != tc.want {
|
||||
|
|
@ -89,25 +77,22 @@ func TestHistoryAt_Defaults(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestDirToolAt — the trailing-slash form floors at "browse" for
|
||||
// every path (the embedded convention sets dir_tool nowhere), and an
|
||||
// on-disk .zddc can override it for a subtree.
|
||||
// TestDirToolAt — the trailing-slash form floors at "browse" for every
|
||||
// path (the embedded convention sets dir_tool nowhere), and an on-disk
|
||||
// .zddc can override it for a subtree.
|
||||
func TestDirToolAt(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
// Nothing declares dir_tool → browse everywhere, including paths
|
||||
// whose default_tool (no-slash form) is something else.
|
||||
for _, p := range []string{
|
||||
filepath.Join(root, "Project-X"),
|
||||
filepath.Join(root, "Project-X", "archive", "Acme", "working"),
|
||||
filepath.Join(root, "Project-X", "archive", "Acme", "mdl"),
|
||||
filepath.Join(root, "Project-X", "working", "Acme"),
|
||||
filepath.Join(root, "Project-X", "mdl", "Acme"),
|
||||
filepath.Join(root, "Project-X", "random", "deep", "folder"),
|
||||
} {
|
||||
if got := DirToolAt(root, p); got != "browse" {
|
||||
t.Errorf("DirToolAt(%q) = %q, want browse", p[len(root):], got)
|
||||
}
|
||||
}
|
||||
// Operator override on a subtree; cascades leaf→root.
|
||||
specialDir := filepath.Join(root, "Special")
|
||||
if err := os.MkdirAll(specialDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -125,237 +110,116 @@ func TestDirToolAt(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestCanonicalFolderAt — structural detection of the canonical
|
||||
// project-layout slots that the browse SPA scope-gates context-menu
|
||||
// actions against.
|
||||
//
|
||||
// After the layout reshape:
|
||||
// - <project>/archive is the only depth-2 canonical
|
||||
// - <project>/archive/<party>/<slot> covers the eight per-party
|
||||
// physical slots (incoming, received, issued, mdl, rsk, working,
|
||||
// staging, reviewing)
|
||||
// - everything else returns ""
|
||||
// TestCanonicalFolderAt — structural detection of the canonical slots the
|
||||
// browse SPA scope-gates context-menu actions against, in the flat-peer
|
||||
// layout: depth-2 peers, depth-3 <peer>/<party> (workspace/register peers
|
||||
// report their peer), and depth-4 archive/<party>/{received,issued}.
|
||||
func TestCanonicalFolderAt(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) }
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{filepath.Join(root, "Project-X", "archive"), "archive"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "incoming"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "received"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "issued"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "mdl"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), "rsk"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), "working"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), "staging"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), "reviewing"},
|
||||
// Project-root virtuals are NOT canonical-folder slots.
|
||||
{filepath.Join(root, "Project-X", "working"), ""},
|
||||
{filepath.Join(root, "Project-X", "staging"), ""},
|
||||
{filepath.Join(root, "Project-X", "reviewing"), ""},
|
||||
{j("archive"), "archive"},
|
||||
{j("ssr"), "ssr"},
|
||||
{j("mdl"), "mdl"},
|
||||
{j("rsk"), "rsk"},
|
||||
{j("incoming"), "incoming"},
|
||||
{j("working"), "working"},
|
||||
{j("staging"), "staging"},
|
||||
{j("reviewing"), "reviewing"},
|
||||
// <peer>/<party> reports the peer (not archive).
|
||||
{j("mdl", "Acme"), "mdl"},
|
||||
{j("working", "Acme"), "working"},
|
||||
{j("incoming", "Acme"), "incoming"},
|
||||
// archive/<party> is not itself a slot; received/issued are.
|
||||
{j("archive", "Acme"), ""},
|
||||
{j("archive", "Acme", "received"), "received"},
|
||||
{j("archive", "Acme", "issued"), "issued"},
|
||||
// Non-slots.
|
||||
{root, ""},
|
||||
{filepath.Join(root, "Project-X"), ""},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), ""},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme"), ""},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming", "2026-05-15_Acme-0042 (RFI) - Foundation"), ""},
|
||||
{filepath.Join(root, "Project-X", "random", "dir"), ""},
|
||||
{j(), ""},
|
||||
{j("mdl", "Acme", "sub"), ""},
|
||||
{j("archive", "Acme", "received", "Acme-0042"), ""},
|
||||
{j("random", "dir"), ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := CanonicalFolderAt(root, tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("CanonicalFolderAt(%q) = %q, want %q",
|
||||
tc.path[len(root):], got, tc.want)
|
||||
if got := CanonicalFolderAt(root, tc.path); got != tc.want {
|
||||
t.Errorf("CanonicalFolderAt(%q) = %q, want %q", tc.path[len(root):], got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for
|
||||
// the per-party lifecycle slots (working/staging/reviewing/incoming)
|
||||
// and false for received/issued/mdl/rsk.
|
||||
// TestAutoOwnAt_FromEmbeddedConvention — auto_own is declared at the
|
||||
// <party> level of the workspace peers (incoming/working/staging/
|
||||
// reviewing); the registers (mdl/rsk/ssr) and the WORM archive don't
|
||||
// auto-own.
|
||||
func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) }
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), false},
|
||||
{j("working", "Acme"), true},
|
||||
{j("staging", "Acme"), true},
|
||||
{j("reviewing", "Acme"), true},
|
||||
{j("incoming", "Acme"), true},
|
||||
{j("mdl", "Acme"), false},
|
||||
{j("rsk", "Acme"), false},
|
||||
{j("archive", "Acme", "received"), false},
|
||||
{j("archive", "Acme", "issued"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := AutoOwnAt(root, tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("AutoOwnAt(%q) = %v, want %v",
|
||||
tc.path[len(root):], got, tc.want)
|
||||
if got := AutoOwnAt(root, tc.path); got != tc.want {
|
||||
t.Errorf("AutoOwnAt(%q) = %v, want %v", tc.path[len(root):], got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestVirtualAt_FromEmbeddedConvention — mdl/rsk under a party are
|
||||
// declared virtual, and the six project-level aggregators are virtual.
|
||||
// Other canonical slots materialise on disk.
|
||||
// TestVirtualAt_FromEmbeddedConvention — the flat-peer layout has no
|
||||
// virtual: directories (every peer is physical), so VirtualAt is false
|
||||
// everywhere unless an operator sets it.
|
||||
func TestVirtualAt_FromEmbeddedConvention(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
||||
// Project-level aggregators.
|
||||
{filepath.Join(root, "Project-X", "ssr"), true},
|
||||
{filepath.Join(root, "Project-X", "mdl"), true},
|
||||
{filepath.Join(root, "Project-X", "rsk"), true},
|
||||
{filepath.Join(root, "Project-X", "working"), true},
|
||||
{filepath.Join(root, "Project-X", "staging"), true},
|
||||
{filepath.Join(root, "Project-X", "reviewing"), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := VirtualAt(root, tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("VirtualAt(%q) = %v, want %v",
|
||||
tc.path[len(root):], got, tc.want)
|
||||
j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) }
|
||||
for _, p := range []string{
|
||||
j("ssr"), j("mdl"), j("rsk"), j("working"), j("staging"), j("reviewing"), j("incoming"),
|
||||
j("mdl", "Acme"), j("working", "Acme"), j("archive", "Acme", "received"),
|
||||
} {
|
||||
if VirtualAt(root, p) {
|
||||
t.Errorf("VirtualAt(%q) = true, want false (no virtual peers)", p[len(root):])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsDeclaredPath_FromEmbeddedConvention — canonical paths under
|
||||
// the convention are declared even on a fresh root; arbitrary paths
|
||||
// are not.
|
||||
// TestIsDeclaredPath_FromEmbeddedConvention — the top-level peers are
|
||||
// cascade-declared even on a fresh root; arbitrary names are not.
|
||||
func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) }
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{filepath.Join(root, "Project-X", "archive"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
|
||||
// Project-root aggregators are also declared.
|
||||
{filepath.Join(root, "Project-X", "working"), true},
|
||||
{filepath.Join(root, "Project-X", "reviewing"), true},
|
||||
{filepath.Join(root, "Project-X", "ssr"), true},
|
||||
{filepath.Join(root, "Project-X", "junk"), false}, // not in convention
|
||||
{j("archive"), true},
|
||||
{j("ssr"), true},
|
||||
{j("incoming"), true},
|
||||
{j("working"), true},
|
||||
{j("staging"), true},
|
||||
{j("reviewing"), true},
|
||||
{j("mdl"), true},
|
||||
{j("rsk"), true},
|
||||
{j("junk"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := IsDeclaredPath(root, tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("IsDeclaredPath(%q) = %v, want %v",
|
||||
tc.path[len(root):], got, tc.want)
|
||||
if got := IsDeclaredPath(root, tc.path); got != tc.want {
|
||||
t.Errorf("IsDeclaredPath(%q) = %v, want %v", tc.path[len(root):], got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project root
|
||||
// the cascade declares archive/ plus the six top-level virtual
|
||||
// aggregator slots (ssr, mdl, rsk, working, staging, reviewing).
|
||||
func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X"))
|
||||
want := map[string]bool{
|
||||
"archive": true,
|
||||
"ssr": true, "mdl": true, "rsk": true,
|
||||
"working": true, "staging": true, "reviewing": true,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want)
|
||||
}
|
||||
for _, n := range got {
|
||||
if !want[n] {
|
||||
t.Errorf("unexpected child %q", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOperatorOverride_DefaultsAreSurfaceable — operator can override
|
||||
// any of the canonical tool defaults by mirroring the structure in an
|
||||
// on-disk .zddc. The override wins.
|
||||
func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "Special", "archive", "Acme", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Operator declares that Special/archive/Acme/working uses
|
||||
// classifier instead of the embedded-default browse.
|
||||
writeZddc(t, filepath.Join(root, "Special", "archive", "Acme", "working"),
|
||||
"default_tool: classifier\n")
|
||||
|
||||
if got := DefaultToolAt(root, filepath.Join(root, "Special", "archive", "Acme", "working")); got != "classifier" {
|
||||
t.Errorf("operator override should set default_tool=classifier, got %q", got)
|
||||
}
|
||||
// Default still applies at other projects.
|
||||
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "archive", "Acme", "working")); got != "browse" {
|
||||
t.Errorf("default convention should hold at unchanged paths, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultToolAt_PropagatesToDescendants — once an ancestor sets
|
||||
// default_tool, descendants inherit it unless they override. So a
|
||||
// path under working/ that isn't explicitly declared in paths: still
|
||||
// gets browse as its default tool.
|
||||
func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
// Deep path under archive/<party>/working/ — not explicitly
|
||||
// mentioned in paths:.
|
||||
deep := filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes", "sub", "deep")
|
||||
if got := DefaultToolAt(root, deep); got != "browse" {
|
||||
t.Errorf("DefaultToolAt(%q) = %q, want browse (cascade propagation)",
|
||||
deep[len(root):], got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoOwnAt_DescendantCanDisable — explicit auto_own:false at a
|
||||
// descendant overrides an ancestor's auto_own:true.
|
||||
func TestAutoOwnAt_DescendantCanDisable(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
deepDir := filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com")
|
||||
if err := os.MkdirAll(deepDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeZddc(t, deepDir, "auto_own: false\n")
|
||||
if got := AutoOwnAt(root, deepDir); got != false {
|
||||
t.Errorf("AutoOwnAt(%q) = %v, want false (descendant override)", deepDir, got)
|
||||
}
|
||||
// Ancestor still has it true.
|
||||
ancestor := filepath.Join(root, "Project-X", "archive", "Acme", "working")
|
||||
if got := AutoOwnAt(root, ancestor); got != true {
|
||||
t.Errorf("AutoOwnAt(%q) = %v, want true (ancestor untouched)", ancestor, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInheritFalse_BlocksEmbeddedDefaults — at the on-disk root,
|
||||
// inherit:false stops the embedded layer from contributing. The
|
||||
// canonical paths are then no longer declared.
|
||||
func TestInheritFalse_BlocksEmbeddedDefaults(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
writeZddc(t, root, "inherit: false\n")
|
||||
// Without the embedded defaults' paths: tree, IsDeclaredPath
|
||||
// returns false for previously-canonical paths.
|
||||
if IsDeclaredPath(root, filepath.Join(root, "Project-X", "archive")) {
|
||||
t.Errorf("with inherit:false at root, archive should not be a declared path")
|
||||
}
|
||||
if DefaultToolAt(root, filepath.Join(root, "Project-X", "archive", "Acme", "working")) != "" {
|
||||
t.Errorf("with inherit:false at root, default_tool should be empty for working")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,29 +2,34 @@ package zddc
|
|||
|
||||
import "strings"
|
||||
|
||||
// Canonical project slots — the fixed lifecycle shape of a project.
|
||||
// Canonical project shape — the fixed set of top-level peers and the
|
||||
// per-party record slots under archive/.
|
||||
//
|
||||
// The binary wires bespoke behavior to each of these names (transmittal at
|
||||
// staging/, plan-review at received/, tables rollups at mdl/rsk, folder-nav
|
||||
// at working/staging/reviewing), so the SET of slot names is a deliberate
|
||||
// hard rule rather than a cascade key. The point of this file is that the
|
||||
// set lives in ONE place: handlers ask the predicates below instead of
|
||||
// re-listing the names, so adding or adjusting a slot is a single edit, not
|
||||
// a hunt across ensure.go / fileapi.go / virtualviews.go / lookups.go.
|
||||
// The binary wires bespoke behavior to these names (tables at
|
||||
// mdl/rsk/ssr, transmittal at staging/, WORM at archive/, the party
|
||||
// registry at ssr/), so the SET of names is a deliberate hard rule
|
||||
// rather than a cascade key. The point of this file is that the set
|
||||
// lives in ONE place: handlers ask the predicates below instead of
|
||||
// re-listing the names, so adding or adjusting a peer is a single edit.
|
||||
//
|
||||
// Note the layering: the slot NAMES are hard-coded here, but per-slot
|
||||
// BEHAVIOR (default_tool, history, worm, auto_own, virtual, …) stays
|
||||
// cascade-driven in defaults.zddc.yaml + on-disk .zddc. This file is
|
||||
// identity/shape only.
|
||||
// Note the layering: the names are hard-coded here, but per-peer
|
||||
// BEHAVIOR (default_tool, worm, party_source, history, auto_own, …)
|
||||
// stays cascade-driven in defaults.zddc.yaml + on-disk .zddc. This file
|
||||
// is identity/shape only.
|
||||
var (
|
||||
// rowSlots: project-level tables rollups (ssr) + the per-party record
|
||||
// folders they aggregate (mdl, rsk).
|
||||
// projectPeers: the flat set of physical directories permitted
|
||||
// directly under a project root. archive/ is the committed record;
|
||||
// the rest are party-partitioned workspaces/registers. Mkdir at the
|
||||
// project root is restricted to these plus system (_/.-prefixed)
|
||||
// names.
|
||||
projectPeers = []string{"archive", "incoming", "working", "staging", "reviewing", "mdl", "rsk", "ssr"}
|
||||
// rowSlots: the tables-rendered register peers. mdl/rsk aggregate
|
||||
// across their party subdirs; ssr is flat (one row file per party)
|
||||
// and is also the authoritative party registry.
|
||||
rowSlots = []string{"ssr", "mdl", "rsk"}
|
||||
// folderNavSlots: project-level folder-nav aggregators.
|
||||
folderNavSlots = []string{"working", "staging", "reviewing"}
|
||||
// perPartySlots: the physical lifecycle folders under archive/<party>/.
|
||||
// (ssr is a file — ssr.yaml — not a folder, so it's not here.)
|
||||
perPartySlots = []string{"incoming", "received", "issued", "mdl", "rsk", "working", "staging", "reviewing"}
|
||||
// perPartySlots: the canonical lifecycle folders under
|
||||
// archive/<party>/ — the committed record, both WORM.
|
||||
perPartySlots = []string{"received", "issued"}
|
||||
)
|
||||
|
||||
func slotIn(set []string, s string) bool {
|
||||
|
|
@ -36,26 +41,14 @@ func slotIn(set []string, s string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// IsRowSlot reports whether slot is a tables-rollup slot (ssr/mdl/rsk).
|
||||
// IsProjectPeer reports whether name is one of the fixed top-level peers
|
||||
// permitted directly under a project root.
|
||||
func IsProjectPeer(name string) bool { return slotIn(projectPeers, strings.ToLower(name)) }
|
||||
|
||||
// IsRowSlot reports whether slot is a tables-rendered register peer
|
||||
// (ssr/mdl/rsk).
|
||||
func IsRowSlot(slot string) bool { return slotIn(rowSlots, slot) }
|
||||
|
||||
// IsFolderNavSlot reports whether slot is a folder-nav lifecycle slot
|
||||
// (working/staging/reviewing).
|
||||
func IsFolderNavSlot(slot string) bool { return slotIn(folderNavSlots, slot) }
|
||||
|
||||
// IsVirtualAggregatorSlot reports whether slot is one of the six
|
||||
// project-level virtual aggregators (row rollups + folder-nav). These have
|
||||
// no physical presence at the project root; content is party-scoped.
|
||||
func IsVirtualAggregatorSlot(slot string) bool {
|
||||
return IsRowSlot(slot) || IsFolderNavSlot(slot)
|
||||
}
|
||||
|
||||
// IsPerPartySlot reports whether slot is a physical per-party lifecycle
|
||||
// folder under archive/<party>/.
|
||||
// IsPerPartySlot reports whether slot is a canonical lifecycle folder
|
||||
// under archive/<party>/ (received/issued).
|
||||
func IsPerPartySlot(slot string) bool { return slotIn(perPartySlots, slot) }
|
||||
|
||||
// virtualAggregatorAlternation returns the six aggregator slot names as a
|
||||
// regex alternation (rowSlots then folderNavSlots) for virtualViewRE.
|
||||
func virtualAggregatorAlternation() string {
|
||||
return strings.Join(append(append([]string{}, rowSlots...), folderNavSlots...), "|")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,35 +25,21 @@ import (
|
|||
func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
// DCs are typically internal employees and ARE in project_team
|
||||
// (which is commonly defined as the *@example.com wildcard). The
|
||||
// embedded defaults restate document_controller:rwcda at every
|
||||
// slot that grants project_team a narrower verb set; the
|
||||
// cascade's within-level union then gives the DC the higher
|
||||
// grant. This fixture mirrors the realistic deployment shape so
|
||||
// the union behavior is actually exercised.
|
||||
// DC authority comes PURELY from the cascade peer grants in
|
||||
// defaults.zddc.yaml — no auto-own / admins: list. DCs are typically
|
||||
// in project_team too (the *@example.com wildcard); the defaults
|
||||
// restate document_controller at each peer so the within-level union
|
||||
// gives the DC the higher grant.
|
||||
writeZddc(t, root, `roles:
|
||||
document_controller:
|
||||
members: ["dc@example.com"]
|
||||
project_team:
|
||||
members: ["*@example.com"]
|
||||
`)
|
||||
// Simulate the auto-own .zddc the file API writes when DC mkdir's
|
||||
// archive/Acme/. Carries the creator email + the document_controller
|
||||
// role per the embedded defaults' auto_own_roles entry.
|
||||
partyDir := filepath.Join(root, "Proj", "archive", "Acme")
|
||||
if err := os.MkdirAll(partyDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeZddc(t, partyDir, `acl:
|
||||
permissions:
|
||||
"dc@example.com": rwcda
|
||||
document_controller: rwcda
|
||||
created_by: dc@example.com
|
||||
`)
|
||||
resetCache()
|
||||
|
||||
dc := "dc@example.com"
|
||||
j := func(p ...string) string { return filepath.Join(append([]string{root, "Proj"}, p...)...) }
|
||||
|
||||
mustVerbs := func(dir string, want string) {
|
||||
t.Helper()
|
||||
|
|
@ -73,61 +59,29 @@ created_by: dc@example.com
|
|||
}
|
||||
|
||||
// Project level: rw (no c).
|
||||
mustVerbs(filepath.Join(root, "Proj"), "rw")
|
||||
// A random subfolder under the project inherits rw (no c).
|
||||
mustVerbs(filepath.Join(root, "Proj", "random-folder"), "rw")
|
||||
// archive/: rwc (can create party folders).
|
||||
mustVerbs(filepath.Join(root, "Proj", "archive"), "rwc")
|
||||
// At the party folder itself: rwcda via the auto-own role grant.
|
||||
mustVerbs(partyDir, "rwcda")
|
||||
// Lifecycle slots inside the party folder inherit rwcda from the
|
||||
// party-level role grant where no slot-local grant overrides.
|
||||
mustVerbs(filepath.Join(partyDir, "working"), "rwcda")
|
||||
mustVerbs(filepath.Join(partyDir, "reviewing"), "rwcda")
|
||||
// incoming/ has explicit document_controller: rwcd
|
||||
// — leaf-wins shadows the rwcda inherited from <party>/.
|
||||
mustVerbs(filepath.Join(partyDir, "incoming"), "rwcd")
|
||||
// staging/ has explicit document_controller: rwcda (rwcd for
|
||||
// transfer + `a` for Plan Review's staging/<tracking>/.zddc).
|
||||
mustVerbs(filepath.Join(partyDir, "staging"), "rwcda")
|
||||
// received/ (WORM): inherited rwcda masked to r + worm-restored c.
|
||||
mustVerbs(filepath.Join(partyDir, "received"), "rc")
|
||||
mustVerbs(filepath.Join(partyDir, "issued"), "rc")
|
||||
mustVerbs(j(), "rw")
|
||||
mustVerbs(j("random-folder"), "rw")
|
||||
// archive subtree is WORM: read + worm-create only (w/d/a stripped).
|
||||
mustVerbs(j("archive"), "rc")
|
||||
mustVerbs(j("archive", "Acme"), "rc")
|
||||
mustVerbs(j("archive", "Acme", "received"), "rc")
|
||||
mustVerbs(j("archive", "Acme", "issued"), "rc")
|
||||
// Workspace peers: full authority via the peer-level DC grant.
|
||||
mustVerbs(j("working", "Acme"), "rwcda")
|
||||
mustVerbs(j("staging", "Acme"), "rwcda")
|
||||
mustVerbs(j("reviewing", "Acme"), "rwcda")
|
||||
mustVerbs(j("incoming", "Acme"), "rwcd")
|
||||
// Registers.
|
||||
mustVerbs(j("mdl", "Acme"), "rwcd")
|
||||
mustVerbs(j("rsk", "Acme"), "rwcd")
|
||||
mustVerbs(j("ssr"), "rwc")
|
||||
|
||||
// NOT subtree-admin anywhere — even when notionally elevated,
|
||||
// the role carries no admin: grant.
|
||||
for _, p := range []string{
|
||||
filepath.Join(root, "Proj"),
|
||||
filepath.Join(root, "Proj", "archive"),
|
||||
partyDir,
|
||||
filepath.Join(partyDir, "working"),
|
||||
filepath.Join(partyDir, "staging"),
|
||||
filepath.Join(partyDir, "reviewing"),
|
||||
filepath.Join(partyDir, "received"),
|
||||
filepath.Join(partyDir, "issued"),
|
||||
} {
|
||||
// NOT subtree-admin anywhere — no admins: grant for the role.
|
||||
for _, p := range []string{j(), j("archive"), j("working", "Acme"), j("ssr"), j("mdl", "Acme")} {
|
||||
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):])
|
||||
}
|
||||
}
|
||||
// And specifically — they CAN'T reach inside a fenced per-user
|
||||
// working home. The fence isolates team-member workspaces from
|
||||
// every other role (including DC) by design.
|
||||
homeDir := filepath.Join(partyDir, "working", "alice@example.com")
|
||||
if err := os.MkdirAll(homeDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeZddc(t, homeDir, `acl:
|
||||
inherit: false
|
||||
permissions:
|
||||
"alice@example.com": rwcda
|
||||
created_by: alice@example.com
|
||||
`)
|
||||
resetCache()
|
||||
chain, _ := EffectivePolicy(root, homeDir)
|
||||
if got := EffectiveVerbs(chain, dc); got != 0 {
|
||||
t.Errorf("doc controller inside alice's fenced home = %q, want empty (fence isolates)", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStandardRoles_DocControllerMultiDC — a second DC added to the
|
||||
|
|
@ -255,13 +209,13 @@ func TestStandardRoles_ProjectTeamInFlightRatchet(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
j := func(p ...string) string { return filepath.Join(append([]string{root, "Proj"}, p...)...) }
|
||||
mustVerbs(j("working", "Acme"), "rc") // create + read at the workspace
|
||||
mustVerbs(j("staging", "Acme"), "rc") // drop + read, no modify
|
||||
mustVerbs(j("reviewing", "Acme"), "rc") // create iteration folders
|
||||
mustVerbs(j("archive", "Acme", "received"), "r") // WORM — read pass-through
|
||||
mustVerbs(j("archive", "Acme", "issued"), "r") // WORM — same
|
||||
mustVerbs(j("incoming", "Acme"), "r") // counterparty drop zone — read only
|
||||
}
|
||||
|
||||
// TestStandardRoles_DocControllerStagingDelete — DC needs `d` at
|
||||
|
|
@ -277,7 +231,7 @@ func TestStandardRoles_DocControllerStagingDelete(t *testing.T) {
|
|||
members: ["dc@example.com"]
|
||||
`)
|
||||
dc := "dc@example.com"
|
||||
chain, err := EffectivePolicy(root, filepath.Join(root, "Proj", "archive", "Acme", "staging"))
|
||||
chain, err := EffectivePolicy(root, filepath.Join(root, "Proj", "staging", "Acme"))
|
||||
if err != nil {
|
||||
t.Fatalf("EffectivePolicy: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,336 +9,54 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// Virtual project-level views.
|
||||
// Project-level helpers for the physical top-level peer layout.
|
||||
//
|
||||
// Six aggregators live at <project>/, all sibling to the only real
|
||||
// top-level directory archive/. None of them materialise on disk; the
|
||||
// server synthesises listings by walking archive/*/ at request time
|
||||
// and (for the tables rollups) rewrites file reads/writes back to
|
||||
// canonical paths inside the per-party folders.
|
||||
//
|
||||
// Two aggregation shapes:
|
||||
//
|
||||
// Row rollups (tables tool):
|
||||
// <project>/ssr one row per party folder under archive/, backed
|
||||
// by archive/<party>/ssr.yaml; synthesised key
|
||||
// `name: <party>` is the identity column.
|
||||
// <project>/mdl one row per *.yaml under archive/<party>/mdl/;
|
||||
// synthesised key `$party: <party>` is the
|
||||
// read-only source-party column. ($-prefix
|
||||
// prevents collision with user-defined fields.)
|
||||
// <project>/rsk same as mdl but for archive/<party>/rsk/.
|
||||
//
|
||||
// Folder-nav (browse tool):
|
||||
// <project>/working list of archive/<party>/working/ that have
|
||||
// non-empty content (in-flight filter). Per-
|
||||
// party click 302s to the canonical path.
|
||||
// <project>/staging same shape over archive/<party>/staging/.
|
||||
// <project>/reviewing same shape over archive/<party>/reviewing/.
|
||||
//
|
||||
// ACL on each synthetic row/folder is evaluated against the canonical
|
||||
// archive/<party>/ chain, so party owners can edit their own data and
|
||||
// non-owners see them read-only.
|
||||
//
|
||||
// URL conventions
|
||||
//
|
||||
// /<project>/ssr/ → directory listing
|
||||
// /<project>/ssr/table.yaml | form.yaml → embedded default spec
|
||||
// /<project>/ssr/<party>.yaml → reads <project>/archive/<party>/ssr.yaml
|
||||
// /<project>/ssr/<party>.yaml.html → form edit (form recognizer)
|
||||
// /<project>/ssr/form.html → "+ Add row" — SSR create
|
||||
//
|
||||
// /<project>/mdl/ → rollup listing
|
||||
// /<project>/mdl/table.yaml | form.yaml → embedded default project-rollup spec
|
||||
// /<project>/mdl/<party>__<file>.yaml → reads <project>/archive/<party>/mdl/<file>.yaml
|
||||
// /<project>/mdl/<party>__<file>.yaml.html → form edit
|
||||
//
|
||||
// /<project>/rsk/ → analogous
|
||||
//
|
||||
// /<project>/working/ → folder-nav listing (parties with content)
|
||||
// /<project>/working/<party>/[<rest>] → 302 to /<project>/archive/<party>/working/<rest>
|
||||
// /<project>/staging/, /<project>/reviewing/ → analogous folder-nav
|
||||
//
|
||||
// Modeled on virtualreceived.go: one resolver produces canonical
|
||||
// paths; every caller (listing builder, file API rewrite, form
|
||||
// recognizer) reads its policy chain from the canonical path.
|
||||
// There is no virtual URL space: every record row is addressed at its
|
||||
// real path (mdl/<party>/<file>.yaml, ssr/<party>.yaml). mdl/ and rsk/
|
||||
// AGGREGATE across their party subdirs — the peer root renders one
|
||||
// table of every party's rows (a $party column derived from the real
|
||||
// subdir name), while <peer>/<party>/ shows that party's rows flat.
|
||||
// ssr/ aggregates naturally (one flat ssr/<party>.yaml per party) and
|
||||
// is the authoritative party registry. These helpers back the
|
||||
// aggregation listing and party-name validation.
|
||||
|
||||
// VirtualViewKind classifies a resolved virtual URL.
|
||||
type VirtualViewKind int
|
||||
|
||||
const (
|
||||
VirtualViewNone VirtualViewKind = iota
|
||||
VirtualViewSSRRoot
|
||||
VirtualViewSSRSpec
|
||||
VirtualViewSSRRow
|
||||
VirtualViewMDLRoot
|
||||
VirtualViewMDLSpec
|
||||
VirtualViewMDLRow
|
||||
VirtualViewRSKRoot
|
||||
VirtualViewRSKSpec
|
||||
VirtualViewRSKRow
|
||||
// Folder-nav: top-level listing of parties with non-empty
|
||||
// content in the named lifecycle slot.
|
||||
VirtualViewFolderNavRoot
|
||||
// Folder-nav: a per-party URL under one of the folder-nav
|
||||
// roots. Resolves to a 302 redirect at canonical
|
||||
// /<project>/archive/<party>/<slot>/<rest>.
|
||||
VirtualViewFolderNavRedir
|
||||
)
|
||||
|
||||
// IsRowKind reports whether k targets a per-party row file (true for
|
||||
// SSRRow, MDLRow, RSKRow).
|
||||
func (k VirtualViewKind) IsRowKind() bool {
|
||||
switch k {
|
||||
case VirtualViewSSRRow, VirtualViewMDLRow, VirtualViewRSKRow:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsSpecKind reports whether k targets a virtual table.yaml/form.yaml.
|
||||
func (k VirtualViewKind) IsSpecKind() bool {
|
||||
switch k {
|
||||
case VirtualViewSSRSpec, VirtualViewMDLSpec, VirtualViewRSKSpec:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsRootKind reports whether k targets the listing-level URL of a
|
||||
// virtual view.
|
||||
func (k VirtualViewKind) IsRootKind() bool {
|
||||
switch k {
|
||||
case VirtualViewSSRRoot, VirtualViewMDLRoot, VirtualViewRSKRoot,
|
||||
VirtualViewFolderNavRoot:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsFolderNavKind reports whether k is one of the folder-nav virtuals
|
||||
// (working, staging, reviewing). Folder-nav views surface a per-party
|
||||
// listing at the root and 302 redirect at every per-party URL.
|
||||
func (k VirtualViewKind) IsFolderNavKind() bool {
|
||||
switch k {
|
||||
case VirtualViewFolderNavRoot, VirtualViewFolderNavRedir:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// VirtualViewResolution captures the result of mapping a URL onto
|
||||
// one of the project-level virtual views. All fields are populated
|
||||
// only when Resolved is true.
|
||||
type VirtualViewResolution struct {
|
||||
Resolved bool
|
||||
Kind VirtualViewKind
|
||||
|
||||
Project string // "<project>"
|
||||
ProjectURL string // "/<project>/"
|
||||
ProjectAbs string // <fsRoot>/<project>
|
||||
|
||||
Slot string // "ssr", "mdl", "rsk", "working", "staging", "reviewing"
|
||||
SlotURL string // "/<project>/<slot>/"
|
||||
|
||||
// Populated for VirtualView*Spec kinds: "table.yaml" or "form.yaml".
|
||||
SpecBase string
|
||||
|
||||
// Populated for VirtualView*Row kinds.
|
||||
Party string // party folder name (e.g. "0330C1")
|
||||
PartyArchive string // <fsRoot>/<project>/archive/<party>
|
||||
CanonicalAbs string // underlying file on disk
|
||||
CanonicalURL string // /<project>/archive/<party>/...
|
||||
SchemaAbs string // SSR only — <party>/ssr.form.yaml (may not exist; falls back to embedded)
|
||||
RowFilename string // MDL/RSK rollups only — e.g. "D-001.yaml"
|
||||
|
||||
// Populated for VirtualViewFolderNavRedir. The path component
|
||||
// AFTER the party — empty for /<project>/<slot>/<party>/ itself,
|
||||
// or the URL-decoded sub-path for deeper URLs. The redirect
|
||||
// target is /<project>/archive/<party>/<slot>/<RedirRest>.
|
||||
RedirRest string
|
||||
}
|
||||
|
||||
// virtualViewRE matches /<project>/<slot>[/<rest>] where slot is one
|
||||
// of the canonical virtual view names. Capture 1 = project, capture
|
||||
// 2 = slot, capture 3 = rest (may be empty).
|
||||
var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(` + virtualAggregatorAlternation() + `)(?:/(.*))?$`)
|
||||
|
||||
// partyNameRE matches the SSR schema's `name` pattern. Same regex
|
||||
// used at row-resolution time so URLs with invalid party tokens fail
|
||||
// resolution cleanly instead of producing impossible canonical paths.
|
||||
// partyNameRE matches a valid party folder / registry-row token —
|
||||
// starts with [A-Za-z0-9], then any of [A-Za-z0-9.-].
|
||||
var partyNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]*$`)
|
||||
|
||||
// ValidPartyName reports whether s is a valid party folder name —
|
||||
// starts with [A-Za-z0-9], then any of [A-Za-z0-9.-]. Used by URL
|
||||
// ValidPartyName reports whether s is a valid party name. Used by URL
|
||||
// resolution AND by the SSR create handler to validate user input.
|
||||
func ValidPartyName(s string) bool {
|
||||
return partyNameRE.MatchString(s)
|
||||
}
|
||||
|
||||
// IsFolderNavSlot / IsRowSlot / IsVirtualAggregatorSlot / IsPerPartySlot
|
||||
// live in slots.go (the single canonical-slot registry).
|
||||
|
||||
// planReviewURLRE matches /<project>/archive/<party>/received/<tracking>/
|
||||
// — the only URL shape Plan Review accepts. Trailing slash optional.
|
||||
var planReviewURLRE = regexp.MustCompile(`^/[^/]+/archive/[^/]+/received/[^/]+/?$`)
|
||||
|
||||
// IsPlanReviewURL reports whether urlPath is a directory URL eligible
|
||||
// for the Plan Review composite endpoint — i.e. it points at the
|
||||
// canonical received/<tracking>/ folder under archive/<party>/. Used
|
||||
// to surface X-ZDDC-On-Plan-Review on directory responses so the
|
||||
// browse client can show/hide the right-click menu item.
|
||||
//
|
||||
// Eligibility is purely structural — no cascade lookup, no per-
|
||||
// project configuration. The handler-side authorisation check still
|
||||
// gates the actual operation.
|
||||
// canonical received/<tracking>/ folder under archive/<party>/.
|
||||
// Eligibility is purely structural; the handler-side authorisation
|
||||
// check still gates the actual operation.
|
||||
func IsPlanReviewURL(urlPath string) bool {
|
||||
return planReviewURLRE.MatchString(urlPath)
|
||||
}
|
||||
|
||||
// ResolveVirtualView inspects urlPath and returns a populated
|
||||
// resolution iff the URL targets one of the project-level virtual
|
||||
// views (ssr/, mdl/, rsk/, working/, staging/, reviewing/).
|
||||
// Resolved=false on non-match.
|
||||
//
|
||||
// The resolver does NOT check that the project / party / row file
|
||||
// actually exist on disk — that's the caller's job (handlers use
|
||||
// the canonical path; listings synthesize from real disk state).
|
||||
//
|
||||
// urlPath must be a server-relative URL with one leading slash.
|
||||
// Trailing slashes are tolerated for root kinds.
|
||||
func ResolveVirtualView(fsRoot, urlPath string) VirtualViewResolution {
|
||||
var out VirtualViewResolution
|
||||
if urlPath == "" || urlPath[0] != '/' {
|
||||
return out
|
||||
// StripYAMLHTML returns urlPath with a trailing ".html" stripped iff
|
||||
// the URL has the form-edit shape ".../<name>.yaml.html". Otherwise
|
||||
// returns urlPath unchanged + false. The form recognizer calls this
|
||||
// to map a form-edit URL onto the underlying data file.
|
||||
func StripYAMLHTML(urlPath string) (string, bool) {
|
||||
if strings.HasSuffix(urlPath, ".yaml.html") {
|
||||
return strings.TrimSuffix(urlPath, ".html"), true
|
||||
}
|
||||
trimmed := strings.TrimSuffix(urlPath, "/")
|
||||
m := virtualViewRE.FindStringSubmatch(trimmed)
|
||||
if m == nil {
|
||||
return out
|
||||
}
|
||||
project := m[1]
|
||||
slot := m[2]
|
||||
rest := m[3]
|
||||
|
||||
if project == "" || strings.HasPrefix(project, ".") || strings.HasPrefix(project, "_") {
|
||||
return out
|
||||
}
|
||||
projectAbs := filepath.Join(fsRoot, filepath.FromSlash(project))
|
||||
if !strings.HasPrefix(projectAbs, fsRoot+string(filepath.Separator)) && projectAbs != fsRoot {
|
||||
return out
|
||||
}
|
||||
|
||||
out.Project = project
|
||||
out.ProjectURL = "/" + project + "/"
|
||||
out.ProjectAbs = projectAbs
|
||||
out.Slot = slot
|
||||
out.SlotURL = "/" + project + "/" + slot + "/"
|
||||
|
||||
if rest == "" {
|
||||
if IsFolderNavSlot(slot) {
|
||||
out.Kind = VirtualViewFolderNavRoot
|
||||
} else {
|
||||
switch slot {
|
||||
case "ssr":
|
||||
out.Kind = VirtualViewSSRRoot
|
||||
case "mdl":
|
||||
out.Kind = VirtualViewMDLRoot
|
||||
case "rsk":
|
||||
out.Kind = VirtualViewRSKRoot
|
||||
}
|
||||
}
|
||||
out.Resolved = true
|
||||
return out
|
||||
}
|
||||
|
||||
// Folder-nav slots: any non-empty rest is a per-party redirect
|
||||
// target. /<project>/working/<party>[/...] → 302 to canonical
|
||||
// /<project>/archive/<party>/working[/...].
|
||||
if IsFolderNavSlot(slot) {
|
||||
// Split off the party (first segment) from the rest.
|
||||
party := rest
|
||||
var redirRest string
|
||||
if idx := strings.Index(rest, "/"); idx >= 0 {
|
||||
party = rest[:idx]
|
||||
redirRest = rest[idx+1:]
|
||||
}
|
||||
if !ValidPartyName(party) {
|
||||
return out
|
||||
}
|
||||
out.Party = party
|
||||
out.PartyArchive = filepath.Join(projectAbs, "archive", party)
|
||||
out.RedirRest = redirRest
|
||||
out.CanonicalURL = "/" + project + "/archive/" + party + "/" + slot + "/"
|
||||
if redirRest != "" {
|
||||
out.CanonicalURL += redirRest
|
||||
}
|
||||
out.Kind = VirtualViewFolderNavRedir
|
||||
out.Resolved = true
|
||||
return out
|
||||
}
|
||||
|
||||
if rest == "table.yaml" || rest == "form.yaml" {
|
||||
switch slot {
|
||||
case "ssr":
|
||||
out.Kind = VirtualViewSSRSpec
|
||||
case "mdl":
|
||||
out.Kind = VirtualViewMDLSpec
|
||||
case "rsk":
|
||||
out.Kind = VirtualViewRSKSpec
|
||||
}
|
||||
out.SpecBase = rest
|
||||
out.Resolved = true
|
||||
return out
|
||||
}
|
||||
|
||||
// Row files — must be a single segment ending in .yaml.
|
||||
if strings.Contains(rest, "/") || !strings.HasSuffix(rest, ".yaml") {
|
||||
return out
|
||||
}
|
||||
name := strings.TrimSuffix(rest, ".yaml")
|
||||
|
||||
if slot == "ssr" {
|
||||
if !ValidPartyName(name) {
|
||||
return out
|
||||
}
|
||||
out.Party = name
|
||||
out.PartyArchive = filepath.Join(projectAbs, "archive", name)
|
||||
out.CanonicalAbs = filepath.Join(out.PartyArchive, "ssr.yaml")
|
||||
out.CanonicalURL = "/" + project + "/archive/" + name + "/ssr.yaml"
|
||||
out.SchemaAbs = filepath.Join(out.PartyArchive, "ssr.form.yaml")
|
||||
out.Kind = VirtualViewSSRRow
|
||||
out.Resolved = true
|
||||
return out
|
||||
}
|
||||
|
||||
// MDL/RSK rollup row — <party>__<file>.yaml.
|
||||
idx := strings.Index(name, "__")
|
||||
if idx <= 0 || idx >= len(name)-2 {
|
||||
return out
|
||||
}
|
||||
party := name[:idx]
|
||||
rowBase := name[idx+2:]
|
||||
if !ValidPartyName(party) || rowBase == "" || strings.Contains(rowBase, "__") {
|
||||
return out
|
||||
}
|
||||
out.Party = party
|
||||
out.PartyArchive = filepath.Join(projectAbs, "archive", party)
|
||||
out.RowFilename = rowBase + ".yaml"
|
||||
out.CanonicalAbs = filepath.Join(out.PartyArchive, slot, out.RowFilename)
|
||||
out.CanonicalURL = "/" + project + "/archive/" + party + "/" + slot + "/" + out.RowFilename
|
||||
switch slot {
|
||||
case "mdl":
|
||||
out.Kind = VirtualViewMDLRow
|
||||
case "rsk":
|
||||
out.Kind = VirtualViewRSKRow
|
||||
}
|
||||
out.Resolved = true
|
||||
return out
|
||||
return urlPath, false
|
||||
}
|
||||
|
||||
// IsSSRCreateURL reports whether urlPath is /<project>/ssr/form.html
|
||||
// — the SSR "+ Add row" target. Returns the project name when matched.
|
||||
// IsSSRCreateURL reports whether urlPath is /<project>/ssr/form.html —
|
||||
// the SSR "+ Register party" target. Returns the project name when
|
||||
// matched. The handler writes the new ssr/<party>.yaml registry row.
|
||||
func IsSSRCreateURL(urlPath string) (string, bool) {
|
||||
if urlPath == "" || urlPath[0] != '/' {
|
||||
return "", false
|
||||
|
|
@ -356,11 +74,10 @@ func IsSSRCreateURL(urlPath string) (string, bool) {
|
|||
|
||||
// IsRollupCreateURL reports whether urlPath is
|
||||
// /<project>/(mdl|rsk)/form.html — the "+ Add row" target on a
|
||||
// project-level MDL or RSK rollup view. Returns the project name +
|
||||
// slot ("mdl" or "rsk") when matched. The rollup-create handler
|
||||
// reads a `party` field from the body and routes the new row into
|
||||
// <project>/archive/<party>/<slot>/.
|
||||
func IsRollupCreateURL(urlPath string) (project, slot string, ok bool) {
|
||||
// project-level MDL or RSK aggregate view. Returns the project name +
|
||||
// peer ("mdl" or "rsk") when matched. The handler reads a `party` field
|
||||
// from the body and routes the new row into <project>/<peer>/<party>/.
|
||||
func IsRollupCreateURL(urlPath string) (project, peer string, ok bool) {
|
||||
if urlPath == "" || urlPath[0] != '/' {
|
||||
return "", "", false
|
||||
}
|
||||
|
|
@ -378,25 +95,13 @@ func IsRollupCreateURL(urlPath string) (project, slot string, ok bool) {
|
|||
return project, parts[1], true
|
||||
}
|
||||
|
||||
// StripYAMLHTML returns urlPath with a trailing ".html" stripped iff
|
||||
// the URL has the form-edit shape ".../<name>.yaml.html". Otherwise
|
||||
// returns urlPath unchanged + false. The form recognizer calls this
|
||||
// before passing the data URL into ResolveVirtualView.
|
||||
func StripYAMLHTML(urlPath string) (string, bool) {
|
||||
if strings.HasSuffix(urlPath, ".yaml.html") {
|
||||
return strings.TrimSuffix(urlPath, ".html"), true
|
||||
}
|
||||
return urlPath, false
|
||||
}
|
||||
|
||||
// ListSSRParties returns the party folder names that exist under
|
||||
// <project>/archive/. Names are filtered through ValidPartyName so a
|
||||
// hand-created folder with a weird name (e.g. "0330C1 (draft)") won't
|
||||
// confuse the rest of the resolver. Returns nil + nil when archive/
|
||||
// doesn't exist on disk.
|
||||
func ListSSRParties(fsRoot, projectAbs string) ([]string, error) {
|
||||
archive := filepath.Join(projectAbs, "archive")
|
||||
entries, err := os.ReadDir(archive)
|
||||
// ListParties returns the registered party names under <project>/ssr/
|
||||
// — one per ssr/<party>.yaml file (the authoritative party registry).
|
||||
// Names are filtered through ValidPartyName. Returns nil + nil when
|
||||
// ssr/ doesn't exist on disk.
|
||||
func ListParties(projectAbs string) ([]string, error) {
|
||||
reg := filepath.Join(projectAbs, "ssr")
|
||||
entries, err := os.ReadDir(reg)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
|
|
@ -405,10 +110,10 @@ func ListSSRParties(fsRoot, projectAbs string) ([]string, error) {
|
|||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
name := strings.TrimSuffix(e.Name(), ".yaml")
|
||||
if !ValidPartyName(name) {
|
||||
continue
|
||||
}
|
||||
|
|
@ -417,119 +122,3 @@ func ListSSRParties(fsRoot, projectAbs string) ([]string, error) {
|
|||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// VirtualRollupRow describes one synthetic row in a project-level
|
||||
// MDL or RSK rollup.
|
||||
type VirtualRollupRow struct {
|
||||
Party string // source party folder
|
||||
Filename string // e.g. "D-001.yaml"
|
||||
SyntheticName string // e.g. "0330C1__D-001.yaml" — used in URLs
|
||||
CanonicalAbs string // underlying file on disk
|
||||
}
|
||||
|
||||
// ListRollupRows walks <project>/archive/*/<slot>/ and returns one
|
||||
// synthetic row per *.yaml file. slot must be "mdl" or "rsk".
|
||||
// Returns rows sorted by (party, filename).
|
||||
//
|
||||
// Skipped:
|
||||
// - filenames containing "__" (would break the party__file split)
|
||||
// - "table.yaml" and "form.yaml" (operator spec/schema, not rows)
|
||||
// - any non-*.yaml file
|
||||
// - parties with invalid folder names (filtered by ListSSRParties)
|
||||
func ListRollupRows(fsRoot, projectAbs, slot string) ([]VirtualRollupRow, error) {
|
||||
if slot != "mdl" && slot != "rsk" {
|
||||
return nil, errors.New("ListRollupRows: slot must be mdl or rsk")
|
||||
}
|
||||
parties, err := ListSSRParties(fsRoot, projectAbs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]VirtualRollupRow, 0, len(parties))
|
||||
for _, party := range parties {
|
||||
slotDir := filepath.Join(projectAbs, "archive", party, slot)
|
||||
entries, err := os.ReadDir(slotDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".yaml") {
|
||||
continue
|
||||
}
|
||||
if name == "table.yaml" || name == "form.yaml" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(name, "__") {
|
||||
continue
|
||||
}
|
||||
out = append(out, VirtualRollupRow{
|
||||
Party: party,
|
||||
Filename: name,
|
||||
SyntheticName: party + "__" + name,
|
||||
CanonicalAbs: filepath.Join(slotDir, name),
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Party != out[j].Party {
|
||||
return out[i].Party < out[j].Party
|
||||
}
|
||||
return out[i].Filename < out[j].Filename
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListPartyDirsInSlot walks <project>/archive/*/<slot>/ and returns
|
||||
// the party folder names whose slot directory exists AND has
|
||||
// non-empty content (the "in-flight" filter). slot must be one of
|
||||
// "working", "staging", "reviewing". Returns nil + nil when archive/
|
||||
// doesn't exist on disk.
|
||||
//
|
||||
// Used by the folder-nav virtuals at <project>/<slot>/ to list only
|
||||
// parties that have something to show. Parties whose archive/<party>/
|
||||
// <slot>/ is absent or contains only system files (.zddc) are
|
||||
// suppressed from the listing.
|
||||
func ListPartyDirsInSlot(fsRoot, projectAbs, slot string) ([]string, error) {
|
||||
if !IsFolderNavSlot(slot) {
|
||||
return nil, errors.New("ListPartyDirsInSlot: slot must be working/staging/reviewing")
|
||||
}
|
||||
parties, err := ListSSRParties(fsRoot, projectAbs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(parties))
|
||||
for _, party := range parties {
|
||||
slotDir := filepath.Join(projectAbs, "archive", party, slot)
|
||||
if !slotDirHasContent(slotDir) {
|
||||
continue
|
||||
}
|
||||
out = append(out, party)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// slotDirHasContent reports whether slotDir is a directory with at
|
||||
// least one entry that isn't a .-prefixed system file. Treats
|
||||
// .zddc-only directories as empty so the folder-nav listing doesn't
|
||||
// fire for parties whose lifecycle slot was scaffolded but never
|
||||
// populated with real work.
|
||||
func slotDirHasContent(slotDir string) bool {
|
||||
entries, err := os.ReadDir(slotDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,331 +7,6 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveVirtualView_Roots(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cases := []struct {
|
||||
url string
|
||||
want VirtualViewKind
|
||||
}{
|
||||
{"/Project/ssr", VirtualViewSSRRoot},
|
||||
{"/Project/ssr/", VirtualViewSSRRoot},
|
||||
{"/Project/mdl", VirtualViewMDLRoot},
|
||||
{"/Project/mdl/", VirtualViewMDLRoot},
|
||||
{"/Project/rsk", VirtualViewRSKRoot},
|
||||
{"/Project/rsk/", VirtualViewRSKRoot},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := ResolveVirtualView(root, tc.url)
|
||||
if !got.Resolved || got.Kind != tc.want {
|
||||
t.Errorf("%s: kind=%d resolved=%v; want kind=%d resolved=true", tc.url, got.Kind, got.Resolved, tc.want)
|
||||
}
|
||||
if got.Project != "Project" {
|
||||
t.Errorf("%s: project=%q want Project", tc.url, got.Project)
|
||||
}
|
||||
if !got.Kind.IsRootKind() {
|
||||
t.Errorf("%s: IsRootKind=false", tc.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveVirtualView_Specs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cases := []struct {
|
||||
url string
|
||||
wantKind VirtualViewKind
|
||||
wantBase string
|
||||
}{
|
||||
{"/Project/ssr/table.yaml", VirtualViewSSRSpec, "table.yaml"},
|
||||
{"/Project/ssr/form.yaml", VirtualViewSSRSpec, "form.yaml"},
|
||||
{"/Project/mdl/table.yaml", VirtualViewMDLSpec, "table.yaml"},
|
||||
{"/Project/mdl/form.yaml", VirtualViewMDLSpec, "form.yaml"},
|
||||
{"/Project/rsk/table.yaml", VirtualViewRSKSpec, "table.yaml"},
|
||||
{"/Project/rsk/form.yaml", VirtualViewRSKSpec, "form.yaml"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := ResolveVirtualView(root, tc.url)
|
||||
if !got.Resolved || got.Kind != tc.wantKind {
|
||||
t.Errorf("%s: kind=%d resolved=%v; want kind=%d", tc.url, got.Kind, got.Resolved, tc.wantKind)
|
||||
}
|
||||
if got.SpecBase != tc.wantBase {
|
||||
t.Errorf("%s: SpecBase=%q want %q", tc.url, got.SpecBase, tc.wantBase)
|
||||
}
|
||||
if !got.Kind.IsSpecKind() {
|
||||
t.Errorf("%s: IsSpecKind=false", tc.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveVirtualView_SSRRow(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
got := ResolveVirtualView(root, "/Project/ssr/0330C1.yaml")
|
||||
if !got.Resolved || got.Kind != VirtualViewSSRRow {
|
||||
t.Fatalf("unexpected resolution: %+v", got)
|
||||
}
|
||||
if got.Party != "0330C1" {
|
||||
t.Errorf("Party=%q want 0330C1", got.Party)
|
||||
}
|
||||
wantAbs := filepath.Join(root, "Project", "archive", "0330C1", "ssr.yaml")
|
||||
if got.CanonicalAbs != wantAbs {
|
||||
t.Errorf("CanonicalAbs=%q want %q", got.CanonicalAbs, wantAbs)
|
||||
}
|
||||
wantSchema := filepath.Join(root, "Project", "archive", "0330C1", "ssr.form.yaml")
|
||||
if got.SchemaAbs != wantSchema {
|
||||
t.Errorf("SchemaAbs=%q want %q", got.SchemaAbs, wantSchema)
|
||||
}
|
||||
if !got.Kind.IsRowKind() {
|
||||
t.Errorf("IsRowKind=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveVirtualView_RollupRow(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cases := []struct {
|
||||
url string
|
||||
wantKind VirtualViewKind
|
||||
wantParty string
|
||||
wantFilename string
|
||||
wantSlot string
|
||||
}{
|
||||
{"/Project/mdl/0330C1__D-001.yaml", VirtualViewMDLRow, "0330C1", "D-001.yaml", "mdl"},
|
||||
{"/Project/rsk/Acme__R-005.yaml", VirtualViewRSKRow, "Acme", "R-005.yaml", "rsk"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := ResolveVirtualView(root, tc.url)
|
||||
if !got.Resolved || got.Kind != tc.wantKind {
|
||||
t.Errorf("%s: kind=%d resolved=%v; want kind=%d", tc.url, got.Kind, got.Resolved, tc.wantKind)
|
||||
continue
|
||||
}
|
||||
if got.Party != tc.wantParty {
|
||||
t.Errorf("%s: Party=%q want %q", tc.url, got.Party, tc.wantParty)
|
||||
}
|
||||
if got.RowFilename != tc.wantFilename {
|
||||
t.Errorf("%s: RowFilename=%q want %q", tc.url, got.RowFilename, tc.wantFilename)
|
||||
}
|
||||
wantAbs := filepath.Join(root, "Project", "archive", tc.wantParty, tc.wantSlot, tc.wantFilename)
|
||||
if got.CanonicalAbs != wantAbs {
|
||||
t.Errorf("%s: CanonicalAbs=%q want %q", tc.url, got.CanonicalAbs, wantAbs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveVirtualView_NonMatches(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cases := []string{
|
||||
"/",
|
||||
"/Project",
|
||||
"/Project/",
|
||||
"/Project/archive/Acme/mdl",
|
||||
"/Project/ssr/invalid__name__double.yaml", // double-double underscore is rejected
|
||||
"/Project/mdl/__leading.yaml", // empty party
|
||||
"/Project/mdl/party__.yaml", // empty rowBase
|
||||
"/Project/ssr/.hidden.yaml", // dotfile party name
|
||||
"/Project/ssr/0330C1.yaml/sub", // sub-path under row file
|
||||
"/Project/notaslot/table.yaml",
|
||||
}
|
||||
for _, url := range cases {
|
||||
got := ResolveVirtualView(root, url)
|
||||
if got.Resolved {
|
||||
t.Errorf("%s: unexpectedly resolved as kind %d", url, got.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveVirtualView_FolderNavRoot — the project-level virtual
|
||||
// folder-nav aggregators resolve to VirtualViewFolderNavRoot for the
|
||||
// bare slot URL (trailing slash optional).
|
||||
func TestResolveVirtualView_FolderNavRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cases := []struct {
|
||||
url string
|
||||
slot string
|
||||
}{
|
||||
{"/Project/working", "working"},
|
||||
{"/Project/working/", "working"},
|
||||
{"/Project/staging", "staging"},
|
||||
{"/Project/staging/", "staging"},
|
||||
{"/Project/reviewing", "reviewing"},
|
||||
{"/Project/reviewing/", "reviewing"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := ResolveVirtualView(root, tc.url)
|
||||
if !got.Resolved || got.Kind != VirtualViewFolderNavRoot {
|
||||
t.Errorf("%s: kind=%d resolved=%v; want FolderNavRoot resolved=true", tc.url, got.Kind, got.Resolved)
|
||||
}
|
||||
if got.Slot != tc.slot {
|
||||
t.Errorf("%s: Slot=%q want %q", tc.url, got.Slot, tc.slot)
|
||||
}
|
||||
if !got.Kind.IsRootKind() {
|
||||
t.Errorf("%s: IsRootKind=false", tc.url)
|
||||
}
|
||||
if !got.Kind.IsFolderNavKind() {
|
||||
t.Errorf("%s: IsFolderNavKind=false", tc.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveVirtualView_FolderNavRedir — URLs deeper than the bare
|
||||
// slot resolve to VirtualViewFolderNavRedir with Party + RedirRest
|
||||
// populated; the dispatcher 302s these to the canonical
|
||||
// archive/<party>/<slot>/<rest> path.
|
||||
func TestResolveVirtualView_FolderNavRedir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cases := []struct {
|
||||
url string
|
||||
wantParty string
|
||||
wantRedirRest string
|
||||
wantCanonical string
|
||||
}{
|
||||
{"/Project/working/Acme", "Acme", "", "/Project/archive/Acme/working/"},
|
||||
{"/Project/working/Acme/", "Acme", "", "/Project/archive/Acme/working/"},
|
||||
{"/Project/staging/Acme/2026-05-15_X (RFI) - T", "Acme", "2026-05-15_X (RFI) - T", "/Project/archive/Acme/staging/2026-05-15_X (RFI) - T"},
|
||||
// Trailing slash is stripped at resolver entry; the dispatcher
|
||||
// re-appends it before issuing the 302 to match the request shape.
|
||||
{"/Project/reviewing/Acme/T-0042/", "Acme", "T-0042", "/Project/archive/Acme/reviewing/T-0042"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := ResolveVirtualView(root, tc.url)
|
||||
if !got.Resolved || got.Kind != VirtualViewFolderNavRedir {
|
||||
t.Errorf("%s: kind=%d resolved=%v; want FolderNavRedir resolved=true", tc.url, got.Kind, got.Resolved)
|
||||
continue
|
||||
}
|
||||
if got.Party != tc.wantParty {
|
||||
t.Errorf("%s: Party=%q want %q", tc.url, got.Party, tc.wantParty)
|
||||
}
|
||||
if got.RedirRest != tc.wantRedirRest {
|
||||
t.Errorf("%s: RedirRest=%q want %q", tc.url, got.RedirRest, tc.wantRedirRest)
|
||||
}
|
||||
if got.CanonicalURL != tc.wantCanonical {
|
||||
t.Errorf("%s: CanonicalURL=%q want %q", tc.url, got.CanonicalURL, tc.wantCanonical)
|
||||
}
|
||||
if !got.Kind.IsFolderNavKind() {
|
||||
t.Errorf("%s: IsFolderNavKind=false", tc.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListPartyDirsInSlot — folder-nav listings include only parties
|
||||
// whose archive/<party>/<slot>/ directory exists AND has non-empty
|
||||
// content (the in-flight filter). Parties with an empty or absent
|
||||
// slot directory are suppressed.
|
||||
func TestListPartyDirsInSlot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
projectAbs := filepath.Join(root, "Project")
|
||||
|
||||
// Acme has working content; Beta has only a .zddc system file
|
||||
// (counts as empty); Gamma has the slot directory but it's
|
||||
// completely empty; Delta doesn't have the slot at all.
|
||||
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Acme", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "Acme", "working", "draft.md"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Beta", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "Beta", "working", ".zddc"), []byte(""), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Gamma", "working"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Delta"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ListPartyDirsInSlot(root, projectAbs, "working")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"Acme"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("ListPartyDirsInSlot(working) = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListPartyDirsInSlot_BadSlot — only the three folder-nav slots
|
||||
// are valid.
|
||||
func TestListPartyDirsInSlot_BadSlot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, bad := range []string{"ssr", "mdl", "rsk", "received", "issued", "incoming", ""} {
|
||||
if _, err := ListPartyDirsInSlot(root, root, bad); err == nil {
|
||||
t.Errorf("expected error for slot=%q (only working/staging/reviewing valid)", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsPlanReviewURL — the eligibility test surfaces the X-ZDDC-On-
|
||||
// Plan-Review header. Matches /<project>/archive/<party>/received/
|
||||
// <tracking>/ with or without trailing slash; everything else returns
|
||||
// false.
|
||||
func TestIsPlanReviewURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{"/Project/archive/Acme/received/Acme-0042", true},
|
||||
{"/Project/archive/Acme/received/Acme-0042/", true},
|
||||
{"/Project/archive/Acme/received", false},
|
||||
{"/Project/archive/Acme/received/", false},
|
||||
{"/Project/archive/Acme/received/Acme-0042/file.pdf", false},
|
||||
{"/Project/archive/Acme/issued/Acme-0042/", false},
|
||||
{"/Project/archive/Acme", false},
|
||||
{"/Project/archive", false},
|
||||
{"/Project", false},
|
||||
{"/", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := IsPlanReviewURL(tc.url); got != tc.want {
|
||||
t.Errorf("IsPlanReviewURL(%q) = %v, want %v", tc.url, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSSRCreateURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
url string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"/Project/ssr/form.html", "Project", true},
|
||||
{"/Other-Project/ssr/form.html", "Other-Project", true},
|
||||
{"/Project/ssr/", "", false},
|
||||
{"/Project/ssr/Acme.yaml.html", "", false},
|
||||
{"/Project/mdl/form.html", "", false},
|
||||
{"/.hidden/ssr/form.html", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := IsSSRCreateURL(tc.url)
|
||||
if ok != tc.wantOK {
|
||||
t.Errorf("%s: ok=%v want %v", tc.url, ok, tc.wantOK)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: project=%q want %q", tc.url, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripYAMLHTML(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"/Project/ssr/Acme.yaml.html", "/Project/ssr/Acme.yaml", true},
|
||||
{"/Project/mdl/foo__bar.yaml.html", "/Project/mdl/foo__bar.yaml", true},
|
||||
{"/Project/ssr/Acme.yaml", "/Project/ssr/Acme.yaml", false},
|
||||
{"/Project/ssr/form.html", "/Project/ssr/form.html", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := StripYAMLHTML(tc.in)
|
||||
if got != tc.want || ok != tc.wantOK {
|
||||
t.Errorf("%s: (%q, %v) want (%q, %v)", tc.in, got, ok, tc.want, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidPartyName(t *testing.T) {
|
||||
ok := []string{"0330C1", "Acme", "Acme.Inc", "Acme-Subsidiary", "a", "0", "0330C1.draft", "X-Y-Z"}
|
||||
bad := []string{"", ".hidden", "_underscore", "Acme/sub", "Acme Inc", "Acme(Inc)", "Acme,Inc", "..", "-leading-dash"}
|
||||
|
|
@ -347,36 +22,117 @@ func TestValidPartyName(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestListSSRParties(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
projectAbs := filepath.Join(root, "Project")
|
||||
for _, party := range []string{"0330C1", "0440P2", "Acme"} {
|
||||
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", party), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
func TestIsPlanReviewURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{"/Project/archive/Acme/received/Acme-0042", true},
|
||||
{"/Project/archive/Acme/received/Acme-0042/", true},
|
||||
{"/Project/archive/Acme/received", false},
|
||||
{"/Project/archive/Acme/received/", false},
|
||||
{"/Project/archive/Acme/received/Acme-0042/file.pdf", false},
|
||||
{"/Project/archive/Acme/issued/Acme-0042/", false},
|
||||
{"/Project/archive/Acme", false},
|
||||
{"/", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := IsPlanReviewURL(tc.url); got != tc.want {
|
||||
t.Errorf("IsPlanReviewURL(%q) = %v, want %v", tc.url, got, tc.want)
|
||||
}
|
||||
}
|
||||
// A file (not a dir) and a hidden folder should be filtered out.
|
||||
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "stray.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", ".hidden"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
parties, err := ListSSRParties(root, projectAbs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"0330C1", "0440P2", "Acme"}
|
||||
if strings.Join(parties, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("got %v, want %v", parties, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSSRParties_NoArchive(t *testing.T) {
|
||||
func TestIsSSRCreateURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
url string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"/Project/ssr/form.html", "Project", true},
|
||||
{"/Other-Project/ssr/form.html", "Other-Project", true},
|
||||
{"/Project/ssr/", "", false},
|
||||
{"/Project/ssr/Acme.yaml.html", "", false},
|
||||
{"/Project/mdl/form.html", "", false},
|
||||
{"/.hidden/ssr/form.html", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := IsSSRCreateURL(tc.url)
|
||||
if ok != tc.wantOK || got != tc.want {
|
||||
t.Errorf("IsSSRCreateURL(%q) = (%q,%v) want (%q,%v)", tc.url, got, ok, tc.want, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRollupCreateURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
url string
|
||||
wantProj string
|
||||
wantPeer string
|
||||
wantOK bool
|
||||
}{
|
||||
{"/Project/mdl/form.html", "Project", "mdl", true},
|
||||
{"/Project/rsk/form.html", "Project", "rsk", true},
|
||||
{"/Project/ssr/form.html", "", "", false},
|
||||
{"/Project/mdl/Acme/form.html", "", "", false},
|
||||
{"/.hidden/mdl/form.html", "", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
proj, peer, ok := IsRollupCreateURL(tc.url)
|
||||
if ok != tc.wantOK || proj != tc.wantProj || peer != tc.wantPeer {
|
||||
t.Errorf("IsRollupCreateURL(%q) = (%q,%q,%v) want (%q,%q,%v)", tc.url, proj, peer, ok, tc.wantProj, tc.wantPeer, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripYAMLHTML(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"/Project/mdl/Acme/D-001.yaml.html", "/Project/mdl/Acme/D-001.yaml", true},
|
||||
{"/Project/ssr/Acme.yaml.html", "/Project/ssr/Acme.yaml", true},
|
||||
{"/Project/ssr/Acme.yaml", "/Project/ssr/Acme.yaml", false},
|
||||
{"/Project/ssr/form.html", "/Project/ssr/form.html", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := StripYAMLHTML(tc.in)
|
||||
if got != tc.want || ok != tc.wantOK {
|
||||
t.Errorf("%s: (%q, %v) want (%q, %v)", tc.in, got, ok, tc.want, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ListParties reads the registry — one ssr/<party>.yaml per registered party.
|
||||
func TestListParties(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
projectAbs := filepath.Join(root, "Project")
|
||||
parties, err := ListSSRParties(root, projectAbs)
|
||||
ssrDir := filepath.Join(projectAbs, "ssr")
|
||||
if err := os.MkdirAll(ssrDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, party := range []string{"0330C1", "0440P2", "Acme"} {
|
||||
if err := os.WriteFile(filepath.Join(ssrDir, party+".yaml"), []byte("kind: SSR\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Non-.yaml files filtered out.
|
||||
_ = os.WriteFile(filepath.Join(ssrDir, "stray.txt"), []byte("x"), 0o644)
|
||||
|
||||
parties, err := ListParties(projectAbs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(parties, ",") != "0330C1,0440P2,Acme" {
|
||||
t.Errorf("ListParties = %v, want [0330C1 0440P2 Acme]", parties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListParties_NoRegistry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parties, err := ListParties(filepath.Join(root, "Project"))
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v want nil", err)
|
||||
}
|
||||
|
|
@ -384,56 +140,3 @@ func TestListSSRParties_NoArchive(t *testing.T) {
|
|||
t.Errorf("got %v, want empty", parties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRollupRows(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
projectAbs := filepath.Join(root, "Project")
|
||||
|
||||
for _, party := range []string{"0330C1", "0440P2"} {
|
||||
mdlDir := filepath.Join(projectAbs, "archive", party, "mdl")
|
||||
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Real rows.
|
||||
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "D-001.yaml"), []byte("id: D-001\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "D-002.yaml"), []byte("id: D-002\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0440P2", "mdl", "D-010.yaml"), []byte("id: D-010\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Skipped: table.yaml, form.yaml, anything containing "__".
|
||||
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "table.yaml"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "form.yaml"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "weird__name.yaml"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := ListRollupRows(root, projectAbs, "mdl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("got %d rows, want 3; rows=%+v", len(rows), rows)
|
||||
}
|
||||
wantNames := []string{"0330C1__D-001.yaml", "0330C1__D-002.yaml", "0440P2__D-010.yaml"}
|
||||
for i, want := range wantNames {
|
||||
if rows[i].SyntheticName != want {
|
||||
t.Errorf("row[%d].SyntheticName=%q want %q", i, rows[i].SyntheticName, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRollupRows_BadSlot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if _, err := ListRollupRows(root, root, "ssr"); err == nil {
|
||||
t.Error("expected error for slot=ssr (only mdl/rsk valid)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
|||
if top.DropTarget != nil {
|
||||
out.DropTarget = top.DropTarget
|
||||
}
|
||||
if top.PartySource != "" {
|
||||
out.PartySource = top.PartySource
|
||||
}
|
||||
if top.History != nil {
|
||||
out.History = top.History
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
// TestWormZoneGrant_EmbeddedConvention — archive/<party>/received and
|
||||
// issued carry `worm: []` in defaults.zddc.yaml, so any path under
|
||||
// those folders is a WORM zone (inWorm=true) with no create-capable
|
||||
// principals (grant=0). Other paths are not WORM zones.
|
||||
// TestWormZoneGrant_EmbeddedConvention — defaults.zddc.yaml declares
|
||||
// `worm: [document_controller]` on archive/, so the ENTIRE archive
|
||||
// subtree is a WORM zone (inWorm=true). With no role members in this
|
||||
// bare fixture the grant for an arbitrary principal is 0. The top-level
|
||||
// workspace/register peers are NOT under archive and are not WORM.
|
||||
func TestWormZoneGrant_EmbeddedConvention(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
|
|
@ -18,13 +19,16 @@ func TestWormZoneGrant_EmbeddedConvention(t *testing.T) {
|
|||
path string
|
||||
wantInWorm bool
|
||||
}{
|
||||
{filepath.Join(root, "Proj", "archive"), true},
|
||||
{filepath.Join(root, "Proj", "archive", "Acme"), true},
|
||||
{filepath.Join(root, "Proj", "archive", "Acme", "received"), true},
|
||||
{filepath.Join(root, "Proj", "archive", "Acme", "issued"), true},
|
||||
{filepath.Join(root, "Proj", "archive", "Acme", "received", "2025-Q1"), true}, // deeper still WORM
|
||||
{filepath.Join(root, "Proj", "archive", "Acme", "incoming"), false},
|
||||
{filepath.Join(root, "Proj", "archive", "Acme", "mdl"), false},
|
||||
{filepath.Join(root, "Proj", "archive", "Acme", "received", "2025-Q1"), true},
|
||||
{filepath.Join(root, "Proj", "incoming"), false},
|
||||
{filepath.Join(root, "Proj", "mdl"), false},
|
||||
{filepath.Join(root, "Proj", "working"), false},
|
||||
{filepath.Join(root, "Proj", "staging"), false},
|
||||
{filepath.Join(root, "Proj", "ssr"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
chain, err := EffectivePolicy(root, tc.path)
|
||||
|
|
|
|||
Loading…
Reference in a new issue