Merge feat/md-history-perfile: admin URL toggle, per-save markdown history, slot registry/history_globs, history→.zddc.d/
Lands the v0.0.27 feature stack: ?admin= URL elevation toggle, the per-save edit-history redesign (now stored under .zddc.d/history/), the canonical-slot registry refactor + history_globs cascade key, and the .devshell removal.
This commit is contained in:
commit
af07fa4f80
24 changed files with 637 additions and 364 deletions
|
|
@ -260,7 +260,7 @@ Format: `trackingNumber_revision (status) - title.extension`
|
||||||
| Image | Chart pin | Embeds |
|
| Image | Chart pin | Embeds |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Prod (Dockerfile.prod, BMCD) | `appVersion: "X.Y.Z"` → tag `zddc-server-v<X.Y.Z>` | Stable-labeled bytes from the tagged release commit |
|
| Prod (Dockerfile.prod, BMCD) | `appVersion: "X.Y.Z"` → tag `zddc-server-v<X.Y.Z>` | Stable-labeled bytes from the tagged release commit |
|
||||||
| Dev (Dockerfile, devshell) | `appVersion: "X.Y.Z"` or `"X.Y.Z-beta-<sha>"` → tag or SHA | Stable or beta-snapshot bytes (whichever the chart points at) |
|
| Dev (Dockerfile) | `appVersion: "X.Y.Z"` or `"X.Y.Z-beta-<sha>"` → tag or SHA | Stable or beta-snapshot bytes (whichever the chart points at) |
|
||||||
| Local dev iteration | n/a | Use `tool/dist/<tool>.html` directly; binary's embedded copy lags |
|
| Local dev iteration | n/a | Use `tool/dist/<tool>.html` directly; binary's embedded copy lags |
|
||||||
|
|
||||||
On-page `{{BUILD_LABEL}}` format (HTML tools only — zddc-server's version comes from the binary itself):
|
On-page `{{BUILD_LABEL}}` format (HTML tools only — zddc-server's version comes from the binary itself):
|
||||||
|
|
@ -401,7 +401,7 @@ Read/aggregate counterpart to the form system. Renders a directory of YAML row f
|
||||||
- **Nested sub-tables** — `<dir>/sub-list/table.yaml` is its own self-contained table at `<dir>/sub-list/table.html`. Composition, not violation.
|
- **Nested sub-tables** — `<dir>/sub-list/table.yaml` is its own self-contained table at `<dir>/sub-list/table.html`. Composition, not violation.
|
||||||
- **Per-row attachments** — `<dir>/<id>.attachments/file.pdf`. Natural sidecar pattern; the row YAML can reference its attachments by relative path.
|
- **Per-row attachments** — `<dir>/<id>.attachments/file.pdf`. Natural sidecar pattern; the row YAML can reference its attachments by relative path.
|
||||||
- **Drafts / staging** — `<dir>/.drafts/<id>.yaml` (dot-prefix → hidden from listings as well as from the table).
|
- **Drafts / staging** — `<dir>/.drafts/<id>.yaml` (dot-prefix → hidden from listings as well as from the table).
|
||||||
- **Per-row history** — `<dir>/.history/<base-without-ext>/<RFC3339Nano>-<sha8>.yaml`. Server-managed; one directory per record, one file per archived revision. See "Records, audit, and history" below.
|
- **Per-row history** — `<dir>/.zddc.d/history/<base-without-ext>/<RFC3339Nano>-<sha8>.yaml`. Server-managed; one directory per record, one file per archived revision. See "Records, audit, and history" below.
|
||||||
|
|
||||||
**Default-MDL fallback at `archive/<party>/mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive/<party>/mdl/`, presence-based discovery is the rule.
|
**Default-MDL fallback at `archive/<party>/mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive/<party>/mdl/`, presence-based discovery is the rule.
|
||||||
|
|
||||||
|
|
@ -433,7 +433,7 @@ Defaults are baked into `defaults.zddc.yaml`; `field_codes:` ships empty (every
|
||||||
- `revision` — `1` on create, `+1` per update
|
- `revision` — `1` on create, `+1` per update
|
||||||
- `previous_sha` — first 8 hex chars of SHA-256 of the prior revision's bytes; absent on create. Forms a hash chain for tamper evidence
|
- `previous_sha` — first 8 hex chars of SHA-256 of the prior revision's bytes; absent on create. Forms a hash chain for tamper evidence
|
||||||
|
|
||||||
**History layout**: for any record at `<dir>/<base>.<ext>`, the prior version is archived at `<dir>/.history/<base>/<RFC3339Nano-UTC>-<sha8>.<ext>` before the live file is overwritten. Per-record subfolder under `.history/` keeps `readdir` cheap and makes party-folder rename move SSR history along atomically (the dot-folder is inside the party folder, so `os.Rename` carries it).
|
**History layout**: for any record at `<dir>/<base>.<ext>`, the prior version is archived at `<dir>/.zddc.d/history/<base>/<RFC3339Nano-UTC>-<sha8>.<ext>` before the live file is overwritten. Per-record subfolder under `.zddc.d/history/` keeps `readdir` cheap and makes party-folder rename move SSR history along atomically (the dot-folder is inside the party folder, so `os.Rename` carries it).
|
||||||
|
|
||||||
**Write ordering**: history first, then live. A crash between the two leaves the prior version safely archived; the retry is idempotent because the history filename is deterministic (timestamp + sha of prior bytes).
|
**Write ordering**: history first, then live. A crash between the two leaves the prior version safely archived; the retry is idempotent because the history filename is deterministic (timestamp + sha of prior bytes).
|
||||||
|
|
||||||
|
|
@ -746,7 +746,7 @@ local path that fails loudly and visibly on the developer's terminal.
|
||||||
- Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two entries per tracking number: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both **serve in place** — the handler streams the first chronologically received copy's bytes back at the `.archive/` URL without redirecting. The per-transmittal URL is intentionally hidden so external links of the form `.archive/<tracking>.html#section` keep tracking the latest revision (a redirect would expose the snapshot URL and people would forward THAT instead). Cache-Control is `no-cache` so each load revalidates against the on-disk file's Last-Modified/ETag; when a new revision lands the resolver picks it and the browser refetches. Modifier files (`<tracking>_<rev>+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree.
|
- Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two entries per tracking number: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both **serve in place** — the handler streams the first chronologically received copy's bytes back at the `.archive/` URL without redirecting. The per-transmittal URL is intentionally hidden so external links of the form `.archive/<tracking>.html#section` keep tracking the latest revision (a redirect would expose the snapshot URL and people would forward THAT instead). Cache-Control is `no-cache` so each load revalidates against the on-disk file's Last-Modified/ETag; when a new revision lands the resolver picks it and the browser refetches. Modifier files (`<tracking>_<rev>+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree.
|
||||||
- ACL is enforced via cascading `.zddc` YAML files — first-explicit-match-wins evaluated bottom-up (deepest level first), with deny checked before allow within a single `.zddc`; default-deny when any `.zddc` exists in the chain. Authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`). Operator-facing detail, anti-patterns, worked layouts, the verify-it-works recipe, and the federal-readiness gap analysis are in `zddc/README.md` § "Access control: the `.zddc` cascade." The architectural framing (cooperating layers, commercial-vs-federal trust model, why archive auto-serves at every directory) is in `ARCHITECTURE.md` § "Server security model."
|
- ACL is enforced via cascading `.zddc` YAML files — first-explicit-match-wins evaluated bottom-up (deepest level first), with deny checked before allow within a single `.zddc`; default-deny when any `.zddc` exists in the chain. Authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`). Operator-facing detail, anti-patterns, worked layouts, the verify-it-works recipe, and the federal-readiness gap analysis are in `zddc/README.md` § "Access control: the `.zddc` cascade." The architectural framing (cooperating layers, commercial-vs-federal trust model, why archive auto-serves at every directory) is in `ARCHITECTURE.md` § "Server security model."
|
||||||
- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page".
|
- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page".
|
||||||
- `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by the dev-shell pod's Caddy to gate `/devshell/*` (code-server) on root-admin status without code-server learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint.
|
- `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by an upstream proxy to gate an admin-only sub-app on root-admin status without that app learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint.
|
||||||
- **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like the `_template/` directory created by the self-contained install snippet, still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable.
|
- **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like the `_template/` directory created by the self-contained install snippet, still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable.
|
||||||
- **Caching on embedded tool HTMLs** (landing, browse served at `/`, plus the five canonical app HTMLs at `<dir>/<app>.html`): `Cache-Control: public, max-age=0, must-revalidate` + content-addressed `ETag` (sha256 hex prefix). Browser revalidates on every load; matching ETag returns `304 Not Modified` with empty body. ETag changes only when the binary is redeployed (computed once at startup from `EmbeddedBytes` + `BuildVer`, memoized).
|
- **Caching on embedded tool HTMLs** (landing, browse served at `/`, plus the five canonical app HTMLs at `<dir>/<app>.html`): `Cache-Control: public, max-age=0, must-revalidate` + content-addressed `ETag` (sha256 hex prefix). Browser revalidates on every load; matching ETag returns `304 Not Modified` with empty body. ETag changes only when the binary is redeployed (computed once at startup from `EmbeddedBytes` + `BuildVer`, memoized).
|
||||||
- **Compression**: gzip middleware (`github.com/klauspost/compress/gzhttp`) wraps the entire mux. Skipped for bodies under 1 KB and for 304 responses. Roughly 75% size reduction on tool HTMLs and JSON listings.
|
- **Compression**: gzip middleware (`github.com/klauspost/compress/gzhttp`) wraps the entire mux. Skipped for bodies under 1 KB and for 304 responses. Roughly 75% size reduction on tool HTMLs and JSON listings.
|
||||||
|
|
|
||||||
|
|
@ -470,7 +470,7 @@ app.state.subscribe((property, newValue) => {
|
||||||
|
|
||||||
**Server-side counterpart:** `zddc/internal/handler/formhandler.go` recognizes `*.form.html` and `*.yaml.html` URLs, parses the spec, validates submissions via `zddc/internal/jsonschema/`, writes via `zddc.WriteAtomic` (plain submissions) or `zddc/internal/handler/history.go` `WriteWithHistory` (record-typed YAML — mdl rows, rsk rows, ssr.yaml). Existence of `<name>.form.yaml` is the trigger; without it, the URL falls through to static-file serving.
|
**Server-side counterpart:** `zddc/internal/handler/formhandler.go` recognizes `*.form.html` and `*.yaml.html` URLs, parses the spec, validates submissions via `zddc/internal/jsonschema/`, writes via `zddc.WriteAtomic` (plain submissions) or `zddc/internal/handler/history.go` `WriteWithHistory` (record-typed YAML — mdl rows, rsk rows, ssr.yaml). Existence of `<name>.form.yaml` is the trigger; without it, the URL falls through to static-file serving.
|
||||||
|
|
||||||
**Record-vs-submission distinction.** "Records" are the three table-store types (mdl/rsk/ssr); everything else is a "submission." Records get server-stamped audit fields (`created_at`/`_by`, `updated_at`/`_by`, `revision`, `previous_sha`), an immutable per-record history at `<dir>/.history/<base>/<RFC3339Nano>-<sha8>.<ext>`, cascade-driven filename composition (via the `records:` + `field_codes:` `.zddc` keys), per-folder field locking (e.g. type=RSK in rsk/), and folder-bound fields (`folder_fields`, e.g. originator = party-folder name). The mechanism intercepts at every write entry point — the file-API `serveFilePut` (if `isRecordPath` matches → `WriteWithHistory`, else `WriteAtomic`), the in-dir form create/update (`serveFormCreate`/`serveFormUpdate`), and the project rollup (`serveFormCreateRollup`). Each resolves the `records:` rule for the target directory and, when one with a `filename_format` applies, composes the name via the shared `recordCreatePrep` and routes through `WriteWithHistory`; non-record paths keep the historical date+email `WriteAtomic` write. The convergence means there's no back door that writes an un-stamped, un-composed record. All of it is server-side: the tools opened offline (`file://` / FS-Access, no server) can't enforce audit, composition, `field_codes`, or `folder_fields` — record writes need zddc-server. See AGENTS.md "Records, audit, and history" for the operator surface (incl. the offline gap and pre-folder-binding upgrade notes); `zddc/internal/handler/history.go` for the orchestration.
|
**Record-vs-submission distinction.** "Records" are the three table-store types (mdl/rsk/ssr); everything else is a "submission." Records get server-stamped audit fields (`created_at`/`_by`, `updated_at`/`_by`, `revision`, `previous_sha`), an immutable per-record history at `<dir>/.zddc.d/history/<base>/<RFC3339Nano>-<sha8>.<ext>`, cascade-driven filename composition (via the `records:` + `field_codes:` `.zddc` keys), per-folder field locking (e.g. type=RSK in rsk/), and folder-bound fields (`folder_fields`, e.g. originator = party-folder name). The mechanism intercepts at every write entry point — the file-API `serveFilePut` (if `isRecordPath` matches → `WriteWithHistory`, else `WriteAtomic`), the in-dir form create/update (`serveFormCreate`/`serveFormUpdate`), and the project rollup (`serveFormCreateRollup`). Each resolves the `records:` rule for the target directory and, when one with a `filename_format` applies, composes the name via the shared `recordCreatePrep` and routes through `WriteWithHistory`; non-record paths keep the historical date+email `WriteAtomic` write. The convergence means there's no back door that writes an un-stamped, un-composed record. All of it is server-side: the tools opened offline (`file://` / FS-Access, no server) can't enforce audit, composition, `field_codes`, or `folder_fields` — record writes need zddc-server. See AGENTS.md "Records, audit, and history" for the operator surface (incl. the offline gap and pre-folder-binding upgrade notes); `zddc/internal/handler/history.go` for the orchestration.
|
||||||
|
|
||||||
**Round-trip philosophy:** v0 is "form-as-truth" — submission YAML is regenerated from form state on every save. Hand-edits to submission files are not preserved across re-edit→re-submit. v1 will add an opt-in "file-as-truth" mode (eemeli/yaml Document API) for forms like `.zddc` itself where users hand-edit and comments must survive.
|
**Round-trip philosophy:** v0 is "form-as-truth" — submission YAML is regenerated from form state on every save. Hand-edits to submission files are not preserved across re-edit→re-submit. v1 will add an opt-in "file-as-truth" mode (eemeli/yaml Document API) for forms like `.zddc` itself where users hand-edit and comments must survive.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
// equivalent.
|
// equivalent.
|
||||||
//
|
//
|
||||||
// Talks to the zddc-server history endpoints on the file's own URL:
|
// Talks to the zddc-server history endpoints on the file's own URL:
|
||||||
// GET <url>?history=1 → JSON [{ts, by, sha, prev, bytes, current}]
|
// GET <url>?history=1 → JSON [{ts, by, id, bytes, current}]
|
||||||
// GET <url>?history=<sha> → that version's raw bytes
|
// GET <url>?history=<id> → that version's raw bytes (id = snapshot filename)
|
||||||
// Restore re-PUTs a chosen version's bytes to <url>, which the server
|
// Restore re-PUTs a chosen version's bytes to <url>, which the server
|
||||||
// records as a new version (forward-only; never destructive).
|
// records as a new version (forward-only; never destructive).
|
||||||
//
|
//
|
||||||
|
|
@ -67,8 +67,8 @@
|
||||||
return Array.isArray(data) ? data : [];
|
return Array.isArray(data) ? data : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchVersion(node, sha) {
|
async function fetchVersion(node, id) {
|
||||||
var resp = await fetch(histURL(node.url, sha), { credentials: 'same-origin' });
|
var resp = await fetch(histURL(node.url, id), { credentials: 'same-origin' });
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
return await resp.text();
|
return await resp.text();
|
||||||
}
|
}
|
||||||
|
|
@ -171,17 +171,17 @@
|
||||||
cb.className = 'md-history-pick';
|
cb.className = 'md-history-pick';
|
||||||
cb.addEventListener('change', function () {
|
cb.addEventListener('change', function () {
|
||||||
if (cb.checked) {
|
if (cb.checked) {
|
||||||
selected.push(ent.sha);
|
selected.push(ent.id);
|
||||||
// Keep at most two: drop the oldest selection.
|
// Keep at most two: drop the oldest selection.
|
||||||
if (selected.length > 2) {
|
if (selected.length > 2) {
|
||||||
var dropped = selected.shift();
|
var dropped = selected.shift();
|
||||||
var others = list.querySelectorAll('.md-history-pick');
|
var others = list.querySelectorAll('.md-history-pick');
|
||||||
others.forEach(function (o, i) {
|
others.forEach(function (o, i) {
|
||||||
if (o !== cb && entries[i] && entries[i].sha === dropped) o.checked = false;
|
if (o !== cb && entries[i] && entries[i].id === dropped) o.checked = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
selected = selected.filter(function (s) { return s !== ent.sha; });
|
selected = selected.filter(function (s) { return s !== ent.id; });
|
||||||
}
|
}
|
||||||
syncDiffBtn();
|
syncDiffBtn();
|
||||||
});
|
});
|
||||||
|
|
@ -220,7 +220,7 @@
|
||||||
if (selected.length !== 2) return;
|
if (selected.length !== 2) return;
|
||||||
// Order oldest→newest by the entries' position (newest
|
// Order oldest→newest by the entries' position (newest
|
||||||
// first in the list), so the diff reads old → new.
|
// first in the list), so the diff reads old → new.
|
||||||
var picks = entries.filter(function (e) { return selected.indexOf(e.sha) !== -1; });
|
var picks = entries.filter(function (e) { return selected.indexOf(e.id) !== -1; });
|
||||||
picks.sort(function (a, b) { return (a.ts < b.ts ? -1 : 1); });
|
picks.sort(function (a, b) { return (a.ts < b.ts ? -1 : 1); });
|
||||||
renderDiff(modal, node, picks[0], picks[1], entries);
|
renderDiff(modal, node, picks[0], picks[1], entries);
|
||||||
}
|
}
|
||||||
|
|
@ -237,7 +237,7 @@
|
||||||
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
||||||
var text;
|
var text;
|
||||||
try {
|
try {
|
||||||
text = await fetchVersion(node, ent.sha);
|
text = await fetchVersion(node, ent.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
body.innerHTML = '';
|
body.innerHTML = '';
|
||||||
var err = document.createElement('p');
|
var err = document.createElement('p');
|
||||||
|
|
@ -274,8 +274,8 @@
|
||||||
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
||||||
var oldText, newText;
|
var oldText, newText;
|
||||||
try {
|
try {
|
||||||
oldText = await fetchVersion(node, oldEnt.sha);
|
oldText = await fetchVersion(node, oldEnt.id);
|
||||||
newText = await fetchVersion(node, newEnt.sha);
|
newText = await fetchVersion(node, newEnt.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
body.innerHTML = '';
|
body.innerHTML = '';
|
||||||
var err = document.createElement('p');
|
var err = document.createElement('p');
|
||||||
|
|
@ -347,7 +347,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
var text = await fetchVersion(node, ent.sha);
|
var text = await fetchVersion(node, ent.id);
|
||||||
var resp = await fetch(node.url, {
|
var resp = await fetch(node.url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
// shared/elevation.js — admin elevation toggle.
|
// shared/elevation.js — admin elevation via URL toggle.
|
||||||
//
|
//
|
||||||
// Sudo-style model: admins behave as normal users by default; clicking
|
// Sudo-style model: admins behave as normal users by default; elevating
|
||||||
// the header toggle elevates the session so admin escape hatches (WORM
|
// the session turns on admin escape hatches (WORM bypass, .zddc edit
|
||||||
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
|
// authority, profile admin scaffolds). State is carried in a
|
||||||
// State is carried in a `zddc-elevate=1` cookie that the server reads
|
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
|
||||||
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
|
// → zddc.Principal{Elevated}.
|
||||||
//
|
//
|
||||||
// Only renders the toggle when /.profile/access reports the caller has
|
// Toggle is by URL query param — `?admin=true` to arm, `?admin=false`
|
||||||
// some admin scope — a non-admin sees nothing, which keeps the chrome
|
// (or the red banner's "Drop admin" button) to drop — so it's reachable
|
||||||
// quiet for the common case. The toggle fades in once access loads so
|
// from ANY zddc-server page, not just ones that render a header control.
|
||||||
// non-admins never even see the affordance flash.
|
// The cookie is the sticky state: it persists across navigation for its
|
||||||
|
// Max-Age window, so the param need not stay in the URL (we strip it).
|
||||||
|
// Arming is gated on /.profile/access `can_elevate`, so only real admins
|
||||||
|
// can set it; a non-admin's ?admin=true is a silent no-op.
|
||||||
//
|
//
|
||||||
// Click flow: set/clear the cookie, then reload the page so the server
|
// Applying the cookie reloads to the cleaned URL so the server re-renders
|
||||||
// sees the new state on the next render. The reload is intentional —
|
// under the new state (admin scaffolds in some tool HTML are server-
|
||||||
// admin scaffolds in tool HTML are server-rendered for some tools, so
|
// rendered, so a client-only flip wouldn't reach them). The red viewport
|
||||||
// a soft state flip on the client alone wouldn't reach those.
|
// border + banner (applyArmedChrome) reflect the cookie on every load.
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
@ -59,22 +62,65 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(host, elevated) {
|
// ── URL toggle: ?admin=true | ?admin=false (typeable anywhere) ──────
|
||||||
host.classList.remove('hidden');
|
//
|
||||||
host.innerHTML =
|
// Admin mode is toggled via a URL query param rather than an on-screen
|
||||||
'<input type="checkbox" id="elevation-checkbox"'
|
// checkbox, so it's reachable from any zddc-server page. The param only
|
||||||
+ (elevated ? ' checked' : '') + '>'
|
// SETS the cookie; the cookie is the sticky state (it persists across
|
||||||
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
|
// navigation for its Max-Age window and is what the server reads), so
|
||||||
+ 'Admin</label>';
|
// there's no need to keep ?admin= in the URL once applied.
|
||||||
var cb = host.querySelector('#elevation-checkbox');
|
|
||||||
cb.addEventListener('change', function () {
|
// adminParam returns true/false for a recognised ?admin= value, or null
|
||||||
setElevated(cb.checked);
|
// when absent / unrecognised (ignored).
|
||||||
// Hard reload so server-rendered admin surfaces (profile
|
function adminParam() {
|
||||||
// page scaffolds, hidden-entry listings) catch up. URL
|
try {
|
||||||
// and scroll state are preserved by the browser's normal
|
var v = new URLSearchParams(window.location.search).get('admin');
|
||||||
// back-forward cache rules.
|
if (v === null) return null;
|
||||||
window.location.reload();
|
v = v.toLowerCase();
|
||||||
});
|
if (v === 'true' || v === '1' || v === 'on' || v === 'yes') return true;
|
||||||
|
if (v === 'false' || v === '0' || v === 'off' || v === 'no') return false;
|
||||||
|
return null;
|
||||||
|
} catch (_e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlWithoutAdmin returns the current URL with the admin param stripped
|
||||||
|
// (other params + hash preserved) — what we navigate/replace to so the
|
||||||
|
// dirty param isn't bookmarked and Back doesn't re-trigger it.
|
||||||
|
function urlWithoutAdmin() {
|
||||||
|
var u = new URL(window.location.href);
|
||||||
|
u.searchParams.delete('admin');
|
||||||
|
var qs = u.searchParams.toString();
|
||||||
|
return u.pathname + (qs ? '?' + qs : '') + u.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminParam applies a ?admin= request. Returns true when a
|
||||||
|
// navigation (reload) is underway so the caller can stop. Enabling is
|
||||||
|
// gated on can_elevate — a non-admin who types ?admin=true just gets
|
||||||
|
// the param stripped, never a misleading red border. Disabling is open
|
||||||
|
// (anyone may drop a cookie they somehow hold).
|
||||||
|
async function handleAdminParam() {
|
||||||
|
var want = adminParam();
|
||||||
|
if (want === null) return false;
|
||||||
|
var clean = urlWithoutAdmin();
|
||||||
|
if (want === isElevated()) {
|
||||||
|
// Already in the requested state — just clean the URL, no reload.
|
||||||
|
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (want === true) {
|
||||||
|
var access = await fetchAccess();
|
||||||
|
if (!access || !access.can_elevate) {
|
||||||
|
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setElevated(true);
|
||||||
|
} else {
|
||||||
|
setElevated(false);
|
||||||
|
}
|
||||||
|
// Navigate to the clean URL (a real load, so the server re-renders
|
||||||
|
// under the new cookie) and replace history so Back is safe.
|
||||||
|
window.location.replace(clean);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page-wide affordances when elevation is active. The toggle alone
|
// Page-wide affordances when elevation is active. The toggle alone
|
||||||
|
|
@ -116,26 +162,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
// Body chrome applies on every page load whether or not the
|
// Apply (or tear down) the red border + banner from the cookie on
|
||||||
// header has a toggle slot — the banner needs to surface in
|
// every page load — admin mode is toggled by URL, but the armed
|
||||||
// tools / pages that don't host the toggle (e.g. iframed
|
// chrome must surface everywhere so the user can't accidentally
|
||||||
// classifier inside browse's grid mode), so the user can't
|
// write through an elevated context on a page they didn't toggle.
|
||||||
// accidentally write through an elevated context elsewhere.
|
|
||||||
applyArmedChrome(isElevated());
|
applyArmedChrome(isElevated());
|
||||||
|
|
||||||
var host = document.getElementById('elevation-toggle');
|
// Honour ?admin=true|false typed into any zddc-server URL. There's
|
||||||
if (!host) return; // tool doesn't include the slot yet — no-op
|
// no on-screen toggle anymore — the URL is the enable path and the
|
||||||
var access = await fetchAccess();
|
// red banner's "Drop admin" button is the one-click disable.
|
||||||
if (!access) return; // anonymous / endpoint missing — no-op
|
await handleAdminParam();
|
||||||
// Surface ONLY for users who have admin authority somewhere.
|
|
||||||
// /.profile/access ships `can_elevate` as an elevation-
|
|
||||||
// INDEPENDENT signal — true for any user named in any admin
|
|
||||||
// list, regardless of current cookie state. The other flags
|
|
||||||
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
|
|
||||||
// authority and would be false for an un-elevated admin
|
|
||||||
// who hasn't toggled yet — so we can't gate on those.
|
|
||||||
if (!access.can_elevate) return;
|
|
||||||
render(host, isElevated());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|
|
||||||
|
|
@ -796,7 +796,7 @@ there at all in the worked example), and no other vendor's name leaks via listin
|
||||||
|
|
||||||
Two prefixes are filtered from listings under `ZDDC_ROOT`:
|
Two prefixes are filtered from listings under `ZDDC_ROOT`:
|
||||||
|
|
||||||
- **`.`-prefixed** (e.g. `/.devshell/`, `/Project-A/.internal/notes.md`) — excluded
|
- **`.`-prefixed** (e.g. `/.zddc.d/`, `/Project-A/.internal/notes.md`) — excluded
|
||||||
from listings **and** 404 on direct HTTP access. The recognized virtual prefixes
|
from listings **and** 404 on direct HTTP access. The recognized virtual prefixes
|
||||||
(`.archive`, `.admin`) are explicitly permitted through. This lets operators store
|
(`.archive`, `.admin`) are explicitly permitted through. This lets operators store
|
||||||
side-state (caches, dev-shell home dirs, snapshot staging) on the same volume
|
side-state (caches, dev-shell home dirs, snapshot staging) on the same volume
|
||||||
|
|
@ -1489,13 +1489,13 @@ The intended use case is gating *adjacent* services on the same pod / host
|
||||||
that don't have their own ACL. Concretely: the dev-shell deployment runs
|
that don't have their own ACL. Concretely: the dev-shell deployment runs
|
||||||
both `zddc-server` and `code-server` behind one Caddy listener; Caddy uses
|
both `zddc-server` and `code-server` behind one Caddy listener; Caddy uses
|
||||||
`forward_auth` to ask `/.auth/admin` whether the caller is allowed to reach
|
`forward_auth` to ask `/.auth/admin` whether the caller is allowed to reach
|
||||||
`/devshell/*` (the IDE) before forwarding. zddc-server's own routes (`/`,
|
an admin-only sub-app before forwarding. zddc-server's own routes (`/`,
|
||||||
`/<project>/`, `/.archive/`, etc.) keep their existing `.zddc`-cascade ACL
|
`/<project>/`, `/.archive/`, etc.) keep their existing `.zddc`-cascade ACL
|
||||||
and don't go through this endpoint.
|
and don't go through this endpoint.
|
||||||
|
|
||||||
```caddy
|
```caddy
|
||||||
# example: protect /devshell/* with forward_auth on /.auth/admin
|
# example: protect an admin-only sub-app with forward_auth on /.auth/admin
|
||||||
handle_path /devshell/* {
|
handle_path /admin-app/* {
|
||||||
forward_auth 127.0.0.1:9090 {
|
forward_auth 127.0.0.1:9090 {
|
||||||
uri /.auth/admin
|
uri /.auth/admin
|
||||||
copy_headers X-Auth-Request-Email
|
copy_headers X-Auth-Request-Email
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,6 @@ func main() {
|
||||||
slog.Info("archive periodic rescan disabled (interval=0)")
|
slog.Info("archive periodic rescan disabled (interval=0)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// HTTP handler
|
// HTTP handler
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
// Middleware chain (outermost → innermost):
|
// Middleware chain (outermost → innermost):
|
||||||
|
|
@ -826,11 +825,14 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
|
|
||||||
// Reserve dot-prefixed path segments. The listing pipeline already hides
|
// Reserve dot-prefixed path segments. The listing pipeline already hides
|
||||||
// hidden entries (internal/fs/tree.go:90, projectshandler.go:40),
|
// hidden entries (internal/fs/tree.go:90, projectshandler.go:40),
|
||||||
// but direct URL access would still serve them. 404 here so hidden trees
|
// but direct URL access would still serve them. 404 here so server
|
||||||
// like /srv/.devshell (the in-image dev-shell's persistent home dir on
|
// bookkeeping under the reserved .zddc.d/ sidecar (tokens, history, …)
|
||||||
// the same Azure Files PVC as served data) cannot be fetched. The
|
// cannot be fetched raw. The recognized virtual prefixes (.profile
|
||||||
// recognized virtual prefixes (.profile handled above, cfg.IndexPath
|
// handled above, cfg.IndexPath handled below) are explicitly allowed
|
||||||
// handled below) are explicitly allowed through.
|
// through.
|
||||||
|
//
|
||||||
|
// (Part B will replace this blanket block with a .zddc.d/ admin-fence so
|
||||||
|
// dot-content is uniformly ACL-governed; until then the block stands.)
|
||||||
//
|
//
|
||||||
// Also reserve the apps cache directory (`_app`): the cached HTML files
|
// Also reserve the apps cache directory (`_app`): the cached HTML files
|
||||||
// there must be served via the apps resolver (with proper headers and
|
// there must be served via the apps resolver (with proper headers and
|
||||||
|
|
@ -1332,12 +1334,13 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
// Non-history paths fall through to the normal file serve.
|
// Non-history paths fall through to the normal file serve.
|
||||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Has("history") {
|
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Has("history") {
|
||||||
version := r.URL.Query().Get("history")
|
version := r.URL.Query().Get("history")
|
||||||
if handler.IsTextHistoryCandidate(absPath) {
|
if handler.IsTextHistoryCandidate(cfg.Root, absPath) {
|
||||||
if chain.EffectiveHistory() {
|
// Reading recorded history does NOT require history to be
|
||||||
handler.ServeTextHistory(w, r, absPath, version)
|
// currently enabled — snapshots already on disk stay readable
|
||||||
} else {
|
// (empty list when there are none) even if the `history:` flag
|
||||||
http.NotFound(w, r)
|
// was later turned off. The file's read ACL was already checked
|
||||||
}
|
// above; WRITES remain gated by EffectiveHistory in serveFilePut.
|
||||||
|
handler.ServeTextHistory(w, r, cfg.Root, absPath, version)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler.ServeHistoryList(w, r, absPath)
|
handler.ServeHistoryList(w, r, absPath)
|
||||||
|
|
|
||||||
|
|
@ -26,20 +26,18 @@ import (
|
||||||
// rejects requests whose URL contains a dot-prefixed segment (other than
|
// rejects requests whose URL contains a dot-prefixed segment (other than
|
||||||
// the recognized virtual prefixes .archive and /.profile handled separately).
|
// the recognized virtual prefixes .archive and /.profile handled separately).
|
||||||
//
|
//
|
||||||
// The guard exists so the in-image dev-shell can keep persistent state
|
// The guard keeps server bookkeeping under the reserved .zddc.d/ sidecar
|
||||||
// (settings, source clones, Go module cache) under /srv/.devshell on the
|
// (tokens, history, …) from being fetched raw over HTTP. (Part B will
|
||||||
// same Azure Files PVC as served data without ever exposing those files
|
// replace this blanket block with a .zddc.d/ admin-fence.)
|
||||||
// via direct HTTP fetch.
|
|
||||||
func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
|
func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
||||||
// Realistic shape: a project dir, a hidden top-level dir, and a hidden
|
// Realistic shape: a project dir, a reserved .zddc.d/ token store, and a
|
||||||
// sibling of a normal file inside the project.
|
// hidden sibling of a normal file inside the project.
|
||||||
mustMkdir(t, filepath.Join(root, "Project-A"))
|
mustMkdir(t, filepath.Join(root, "Project-A"))
|
||||||
mustWrite(t, filepath.Join(root, "Project-A", "doc.txt"), "ok")
|
mustWrite(t, filepath.Join(root, "Project-A", "doc.txt"), "ok")
|
||||||
mustMkdir(t, filepath.Join(root, ".devshell"))
|
mustMkdir(t, filepath.Join(root, ".zddc.d", "tokens"))
|
||||||
mustMkdir(t, filepath.Join(root, ".devshell", "coder"))
|
mustWrite(t, filepath.Join(root, ".zddc.d", "tokens", "abc123"), "secret")
|
||||||
mustWrite(t, filepath.Join(root, ".devshell", "coder", "settings.json"), "secret")
|
|
||||||
mustMkdir(t, filepath.Join(root, "Project-A", ".internal"))
|
mustMkdir(t, filepath.Join(root, "Project-A", ".internal"))
|
||||||
mustWrite(t, filepath.Join(root, "Project-A", ".internal", "notes.md"), "secret")
|
mustWrite(t, filepath.Join(root, "Project-A", ".internal", "notes.md"), "secret")
|
||||||
|
|
||||||
|
|
@ -60,9 +58,9 @@ func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
|
||||||
path string
|
path string
|
||||||
wantStatus int
|
wantStatus int
|
||||||
}{
|
}{
|
||||||
// Hidden top-level dir — every shape blocked.
|
// Reserved .zddc.d/ bookkeeping — every shape blocked.
|
||||||
{"hidden top dir", "/.devshell/", http.StatusNotFound},
|
{"reserved .zddc.d dir", "/.zddc.d/", http.StatusNotFound},
|
||||||
{"hidden top dir nested", "/.devshell/coder/settings.json", http.StatusNotFound},
|
{"reserved .zddc.d token", "/.zddc.d/tokens/abc123", http.StatusNotFound},
|
||||||
|
|
||||||
// Hidden segment under a real project dir — also blocked.
|
// Hidden segment under a real project dir — also blocked.
|
||||||
{"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound},
|
{"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound},
|
||||||
|
|
@ -336,11 +334,11 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reserved segment guard still applies to writes.
|
// Reserved segment guard still applies to writes.
|
||||||
req = withEmail(httptest.NewRequest(http.MethodPut, "/.devshell/foo.txt", strings.NewReader("x")), "alice@example.com")
|
req = withEmail(httptest.NewRequest(http.MethodPut, "/.zddc.d/foo.txt", strings.NewReader("x")), "alice@example.com")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Fatalf("PUT /.devshell/...: want 404, got %d", rec.Code)
|
t.Fatalf("PUT /.zddc.d/...: want 404, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1065,3 +1063,9 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (TestDispatchHistoryReadCarveOut was removed: history snapshots now live
|
||||||
|
// under the reserved .zddc.d/history/ namespace — blocked raw by the
|
||||||
|
// dot-prefix guard, like any bookkeeping, and surfaced only through the
|
||||||
|
// history endpoints. Raw-block coverage is in TestDispatchHidesDotPrefixedSegments;
|
||||||
|
// the viewer is covered in mdhistory_test.go.)
|
||||||
|
|
|
||||||
|
|
@ -432,7 +432,7 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
finalBody = res.FinalBody
|
finalBody = res.FinalBody
|
||||||
stamped = true
|
stamped = true
|
||||||
} else if IsTextHistoryCandidate(abs) && zddc.HistoryAt(cfg.Root, filepath.Dir(abs)) {
|
} else if IsTextHistoryCandidate(cfg.Root, abs) && zddc.HistoryAt(cfg.Root, filepath.Dir(abs)) {
|
||||||
// History-enabled text (markdown) files: snapshot every save
|
// History-enabled text (markdown) files: snapshot every save
|
||||||
// into <dir>/.history/<stem>/ with a server-stamped audit line,
|
// into <dir>/.history/<stem>/ with a server-stamped audit line,
|
||||||
// then write the live file. The live file at its natural path
|
// then write the live file. The live file at its natural path
|
||||||
|
|
@ -686,6 +686,24 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
purgeConverted(srcAbs)
|
purgeConverted(srcAbs)
|
||||||
purgeConverted(dstAbs)
|
purgeConverted(dstAbs)
|
||||||
|
|
||||||
|
// Carry edit-history across an in-place rename: if a markdown file was
|
||||||
|
// renamed within the same directory, move its .history/<stem>/ folder to
|
||||||
|
// match the new name. A cross-directory move deliberately leaves history
|
||||||
|
// behind (it lives forever in the dir where the edits happened).
|
||||||
|
if IsTextHistoryCandidate(cfg.Root, srcAbs) && filepath.Dir(srcAbs) == filepath.Dir(dstAbs) {
|
||||||
|
oldHist := mdHistoryDir(srcAbs)
|
||||||
|
newHist := mdHistoryDir(dstAbs)
|
||||||
|
if oldHist != newHist {
|
||||||
|
if _, err := os.Stat(oldHist); err == nil {
|
||||||
|
if _, derr := os.Stat(newHist); errors.Is(derr, os.ErrNotExist) {
|
||||||
|
if rerr := os.Rename(oldHist, newHist); rerr != nil {
|
||||||
|
slog.Warn("rename history dir", "from", oldHist, "to", newHist, "err", rerr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Compute new ETag from the moved bytes for the response — clients
|
// Compute new ETag from the moved bytes for the response — clients
|
||||||
// that want to keep tracking should pin to this ETag.
|
// that want to keep tracking should pin to this ETag.
|
||||||
if etag, err := fileETagOnDisk(dstAbs); err == nil && etag != "" {
|
if etag, err := fileETagOnDisk(dstAbs); err == nil && etag != "" {
|
||||||
|
|
@ -842,8 +860,7 @@ func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) {
|
||||||
return false, ""
|
return false, ""
|
||||||
}
|
}
|
||||||
lower := strings.ToLower(name)
|
lower := strings.ToLower(name)
|
||||||
switch lower {
|
if zddc.IsVirtualAggregatorSlot(lower) {
|
||||||
case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
|
|
||||||
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 + "/."
|
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 + "/."
|
||||||
}
|
}
|
||||||
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 archive/ and system-reserved (_/. prefix) folders may be created directly under a project. Files belong inside archive/<party>/..."
|
||||||
|
|
@ -872,9 +889,7 @@ func rejectProjectAggregatorMkdir(fsRoot, abs string) (bool, string) {
|
||||||
if len(parts) < 3 {
|
if len(parts) < 3 {
|
||||||
return false, "" // depth-2 (the slot itself) is rejectProjectRootMkdir's job
|
return false, "" // depth-2 (the slot itself) is rejectProjectRootMkdir's job
|
||||||
}
|
}
|
||||||
switch strings.ToLower(parts[1]) {
|
if slot := strings.ToLower(parts[1]); zddc.IsVirtualAggregatorSlot(slot) {
|
||||||
case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
|
|
||||||
slot := strings.ToLower(parts[1])
|
|
||||||
return true, "Conflict — " + slot + "/ is a project-level virtual aggregator; folders here belong to a party. " +
|
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."
|
"Create it under archive/<party>/" + slot + "/ — browse's \"New folder\" picker prompts you for the party."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -751,3 +751,52 @@ func TestFileAPI_MkdirInAggregatorRejected(t *testing.T) {
|
||||||
t.Errorf("party-scoped folder not created: %v", err)
|
t.Errorf("party-scoped folder not created: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An in-place rename of a markdown file carries its .history/<stem>/ folder
|
||||||
|
// to the new name; a cross-directory move leaves history behind.
|
||||||
|
func TestFileAPI_MoveRenamesHistoryDir(t *testing.T) {
|
||||||
|
_, do, root := fileAPITestSetup(t, nil, map[string]string{
|
||||||
|
"Docs/notes.md": "# notes\n",
|
||||||
|
})
|
||||||
|
// Pre-seed a history snapshot for notes.md.
|
||||||
|
histOld := filepath.Join(root, "Docs", ".zddc.d", "history", "notes")
|
||||||
|
if err := os.MkdirAll(histOld, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(histOld, "20260101T000000.000Z-a@example.com.md"), []byte("# notes\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-place rename → history dir follows.
|
||||||
|
rec := do(http.MethodPost, "/Docs/notes.md", "alice@example.com", nil, map[string]string{
|
||||||
|
"X-ZDDC-Op": "move",
|
||||||
|
"X-ZDDC-Destination": "/Docs/renamed.md",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("rename: want 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "Docs", ".zddc.d", "history", "renamed")); err != nil {
|
||||||
|
t.Errorf("history dir not renamed to <newstem>: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(histOld); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("old history dir should be gone after rename; err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-directory move → history stays behind in the source dir.
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "Other"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec = do(http.MethodPost, "/Docs/renamed.md", "alice@example.com", nil, map[string]string{
|
||||||
|
"X-ZDDC-Op": "move",
|
||||||
|
"X-ZDDC-Destination": "/Other/renamed.md",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("cross-dir move: want 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "Docs", ".zddc.d", "history", "renamed")); err != nil {
|
||||||
|
t.Errorf("history should stay behind on a cross-dir move: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "Other", ".zddc.d", "history", "renamed")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("history should NOT follow a cross-dir move; err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
//
|
//
|
||||||
// 2. Prior bytes are preserved. Before the live file is overwritten
|
// 2. Prior bytes are preserved. Before the live file is overwritten
|
||||||
// the previous content is copied (byte-for-byte) into
|
// the previous content is copied (byte-for-byte) into
|
||||||
// <dir>/.history/<base>/<RFC3339Nano-UTC>-<sha8>.<ext>. The
|
// <dir>/.zddc.d/history/<base>/<RFC3339Nano-UTC>-<sha8>.<ext>. The
|
||||||
// filename embeds the timestamp + the SHA-256 prefix of the prior
|
// filename embeds the timestamp + the SHA-256 prefix of the prior
|
||||||
// bytes — the same value that's stamped into the new record's
|
// bytes — the same value that's stamped into the new record's
|
||||||
// previous_sha field — so the chain is auditable.
|
// previous_sha field — so the chain is auditable.
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -59,11 +60,14 @@ const (
|
||||||
auditFieldPreviousSha = "previous_sha"
|
auditFieldPreviousSha = "previous_sha"
|
||||||
)
|
)
|
||||||
|
|
||||||
// historyDirName is the dot-prefixed bookkeeping folder under each
|
// historyDirRel is where edit-history snapshots live under each
|
||||||
// record-containing directory. resolveTargetPath's dot-segment
|
// history-tracked directory: <dir>/.zddc.d/history/<stem>/. It sits under
|
||||||
// rejection means no client URL can reach into .history/ — only the
|
// the single reserved .zddc.d/ sidecar namespace (all zddc bookkeeping), so
|
||||||
// server's own history-write code path touches it.
|
// it is reached only through the history endpoints (records:
|
||||||
const historyDirName = ".history"
|
// <record>.yaml?history=1; text: <file>?history=…), which read it
|
||||||
|
// server-side — never as raw browsable content. Both the record and text
|
||||||
|
// history paths share this base.
|
||||||
|
const historyDirRel = ".zddc.d/history"
|
||||||
|
|
||||||
// WriteRecordResult carries what serveFilePut needs to surface a
|
// WriteRecordResult carries what serveFilePut needs to surface a
|
||||||
// response after a successful record write.
|
// response after a successful record write.
|
||||||
|
|
@ -80,8 +84,8 @@ type WriteRecordResult struct {
|
||||||
// Returns:
|
// Returns:
|
||||||
// - res, nil, nil: success; caller writes 200/201 + ETag.
|
// - res, nil, nil: success; caller writes 200/201 + ETag.
|
||||||
// - _, errs, nil: 422 with the validation errors (locked
|
// - _, errs, nil: 422 with the validation errors (locked
|
||||||
// mismatch, field_code violation, filename
|
// mismatch, field_code violation, filename
|
||||||
// composition mismatch).
|
// composition mismatch).
|
||||||
// - _, _, err: internal error; caller writes 500.
|
// - _, _, err: internal error; caller writes 500.
|
||||||
//
|
//
|
||||||
// The function does NOT do ACL, ETag-precondition, or canonical-
|
// The function does NOT do ACL, ETag-precondition, or canonical-
|
||||||
|
|
@ -261,7 +265,7 @@ func WriteWithHistory(cfg config.Config, abs, cleanURL string, body []byte, prin
|
||||||
// (timestamp+sha8 of priorBody) — rewriting it idempotently
|
// (timestamp+sha8 of priorBody) — rewriting it idempotently
|
||||||
// is harmless when the live write later succeeds.
|
// is harmless when the live write later succeeds.
|
||||||
if priorExisted {
|
if priorExisted {
|
||||||
histDir := filepath.Join(dir, historyDirName, stripExt(base))
|
histDir := filepath.Join(dir, historyDirRel, stripExt(base))
|
||||||
if err := os.MkdirAll(histDir, 0o755); err != nil {
|
if err := os.MkdirAll(histDir, 0o755); err != nil {
|
||||||
return WriteRecordResult{}, nil, fmt.Errorf("mkdir history: %w", err)
|
return WriteRecordResult{}, nil, fmt.Errorf("mkdir history: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -609,7 +613,7 @@ type HistoryEntry struct {
|
||||||
func ListHistory(abs string) ([]HistoryEntry, error) {
|
func ListHistory(abs string) ([]HistoryEntry, error) {
|
||||||
dir := filepath.Dir(abs)
|
dir := filepath.Dir(abs)
|
||||||
base := filepath.Base(abs)
|
base := filepath.Base(abs)
|
||||||
histDir := filepath.Join(dir, historyDirName, stripExt(base))
|
histDir := filepath.Join(dir, historyDirRel, stripExt(base))
|
||||||
ents, err := os.ReadDir(histDir)
|
ents, err := os.ReadDir(histDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
|
@ -633,7 +637,7 @@ func ListHistory(abs string) ([]HistoryEntry, error) {
|
||||||
}
|
}
|
||||||
ts := stem[:idx]
|
ts := stem[:idx]
|
||||||
sha := stem[idx+1:]
|
sha := stem[idx+1:]
|
||||||
entry := HistoryEntry{Ts: ts, Sha8: sha, Path: filepath.Join(historyDirName, stripExt(base), name)}
|
entry := HistoryEntry{Ts: ts, Sha8: sha, Path: filepath.Join(historyDirRel, stripExt(base), name)}
|
||||||
// Pull author + revision from the archived body.
|
// Pull author + revision from the archived body.
|
||||||
if data, err := os.ReadFile(filepath.Join(histDir, name)); err == nil {
|
if data, err := os.ReadFile(filepath.Join(histDir, name)); err == nil {
|
||||||
snap := parsePriorAudit(data)
|
snap := parsePriorAudit(data)
|
||||||
|
|
@ -806,209 +810,240 @@ func atoiSafe(s string) int {
|
||||||
// ─── Markdown / text edit-history ────────────────────────────────────────
|
// ─── Markdown / text edit-history ────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// History-enabled text files (a `history: true` .zddc subtree — see
|
// History-enabled text files (a `history: true` .zddc subtree — see
|
||||||
// zddc.PolicyChain.EffectiveHistory) keep every saved version under
|
// zddc.PolicyChain.EffectiveHistory) drop one self-describing snapshot per
|
||||||
// <dir>/.history/<stem>/. Unlike records, text files can't carry audit
|
// save under <dir>/.history/<stem>/:
|
||||||
// fields in-body, so authorship + ordering live in a sidecar log:
|
|
||||||
//
|
//
|
||||||
// .history/<stem>/<contentSha>.md one immutable blob per distinct content
|
// .history/<stem>/<ts>-<email>.<ext> one file per save
|
||||||
// .history/<stem>/log.jsonl one MdHistoryEntry per save, in order
|
|
||||||
//
|
//
|
||||||
// Blobs are content-addressed (so reverting to an earlier exact state
|
// The filename IS the audit record: <ts> is a colon-free UTC timestamp
|
||||||
// reuses its blob); the live file's content always equals the last log
|
// (valid on SMB / Azure Files) and <email> is the authenticated principal.
|
||||||
// entry's sha. Email + timestamp are stamped server-side from the
|
// No sidecar log, no content hashing — listing the directory is the
|
||||||
// authenticated principal — never client-supplied, mirroring the record
|
// history. Reverting copies an old snapshot's bytes onto the live file
|
||||||
// path's anti-forgery stance.
|
// (which then records as a fresh save). Timestamp + email are stamped
|
||||||
|
// server-side, never client-supplied. Snapshots are kept forever: a doc
|
||||||
|
// rename renames its <stem>/ folder (see the move handler); a delete or a
|
||||||
|
// move out of the working dir leaves the history behind.
|
||||||
|
|
||||||
const mdHistoryLogName = "log.jsonl"
|
// mdStampLayout formats the save time WITHOUT colons or hyphens so the
|
||||||
|
// name stays valid on SMB/Windows shares AND splits cleanly on the first
|
||||||
|
// '-' into <ts>-<email>. A literal "Z" is appended separately (a bare "Z"
|
||||||
|
// is not a Go time token). The fixed width sorts lexically by time.
|
||||||
|
const mdStampLayout = "20060102T150405.000"
|
||||||
|
|
||||||
// MdHistoryEntry is one saved version of a history-tracked text file.
|
// MdHistoryEntry is one saved version of a history-tracked text file.
|
||||||
type MdHistoryEntry struct {
|
type MdHistoryEntry struct {
|
||||||
Ts string `json:"ts"` // RFC3339Nano UTC of the save
|
Ts string `json:"ts"` // RFC3339 UTC of the save (parsed from the filename)
|
||||||
By string `json:"by"` // authenticated principal email ("" if pre-history)
|
By string `json:"by"` // authoring principal email ("unknown" if pre-history)
|
||||||
Sha string `json:"sha"` // content hash = version id = blob stem
|
ID string `json:"id"` // the snapshot filename — opaque version id for ?history=<id>
|
||||||
Prev string `json:"prev,omitempty"` // prior version's sha
|
Bytes int64 `json:"bytes"` // size of this version
|
||||||
Bytes int `json:"bytes"` // size of this version
|
Current bool `json:"current,omitempty"` // derived by ListMdHistory: the version matching the live file
|
||||||
Current bool `json:"current,omitempty"` // derived by ListMdHistory; never persisted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mdVersionIDRe validates a client-supplied version id so it can't
|
// IsTextHistoryCandidate reports whether abs is eligible for text edit-
|
||||||
// escape the history dir. Version ids are lowercase hex content hashes.
|
// history at its location: its basename matches the effective history globs
|
||||||
var mdVersionIDRe = regexp.MustCompile(`^[0-9a-f]{1,64}$`)
|
// from the .zddc cascade (default "*.md", widen per-deployment via the
|
||||||
|
// `history_globs:` key). fsRoot is the server root for cascade resolution.
|
||||||
|
func IsTextHistoryCandidate(fsRoot, abs string) bool {
|
||||||
|
return matchHistoryGlobs(zddc.HistoryGlobsAt(fsRoot, filepath.Dir(abs)), filepath.Base(abs))
|
||||||
|
}
|
||||||
|
|
||||||
// IsTextHistoryCandidate reports whether abs is a text file eligible for
|
// matchHistoryGlobs reports whether base matches any of the globs
|
||||||
// edit-history versioning. Scoped to markdown for now (the browse editor
|
// (case-insensitively, so .MD matches *.md).
|
||||||
// surface); widen here to add .txt etc.
|
func matchHistoryGlobs(globs []string, base string) bool {
|
||||||
func IsTextHistoryCandidate(abs string) bool {
|
lb := strings.ToLower(base)
|
||||||
return strings.EqualFold(filepath.Ext(abs), ".md")
|
for _, g := range globs {
|
||||||
|
if ok, _ := filepath.Match(strings.ToLower(g), lb); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func mdHistoryDir(abs string) string {
|
func mdHistoryDir(abs string) string {
|
||||||
return filepath.Join(filepath.Dir(abs), historyDirName, stripExt(filepath.Base(abs)))
|
return filepath.Join(filepath.Dir(abs), historyDirRel, stripExt(filepath.Base(abs)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteTextWithHistory snapshots prior and new content into
|
// mdStamp renders t as the colon-free snapshot timestamp with the trailing
|
||||||
// .history/<stem>/, appends an audit line, then writes the live file
|
// Z, e.g. 20260602T143000.123Z.
|
||||||
// atomically. No-op saves (content identical to the current head) don't
|
func mdStamp(t time.Time) string {
|
||||||
// create a version. History is written BEFORE the live file so a crash
|
return t.UTC().Format(mdStampLayout) + "Z"
|
||||||
// can't lose a version the live write would have superseded.
|
}
|
||||||
|
|
||||||
|
// sanitizeForFilename keeps a principal email filesystem-safe. Emails are
|
||||||
|
// already SMB-safe (@ . - _ +); only a path separator or control char would
|
||||||
|
// be a problem, so collapse anything outside that set to '_'. Empty → the
|
||||||
|
// "unknown" sentinel used for pre-history seed snapshots.
|
||||||
|
func sanitizeForFilename(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||||||
|
r == '@', r == '.', r == '_', r == '+', r == '-':
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
b.WriteRune('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHistoryName splits a snapshot filename "<ts>-<email>.<ext>" into a
|
||||||
|
// display timestamp (RFC3339) and the author email. ok is false for names
|
||||||
|
// that don't match the scheme (so foreign files in the dir are ignored).
|
||||||
|
// The ts field has no '-', so the first '-' is the ts/email boundary; the
|
||||||
|
// email may itself contain '-' (e.g. a hyphenated domain).
|
||||||
|
func parseHistoryName(name string) (tsRFC3339, email string, ok bool) {
|
||||||
|
ext := filepath.Ext(name)
|
||||||
|
stem := strings.TrimSuffix(name, ext)
|
||||||
|
dash := strings.IndexByte(stem, '-')
|
||||||
|
if dash <= 0 || dash == len(stem)-1 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
tsRaw := strings.TrimSuffix(stem[:dash], "Z")
|
||||||
|
t, err := time.Parse(mdStampLayout, tsRaw)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return t.UTC().Format(time.RFC3339), stem[dash+1:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteTextWithHistory drops a snapshot of the new content into
|
||||||
|
// .history/<stem>/<ts>-<email>.<ext>, then writes the live file. A save
|
||||||
|
// byte-identical to the live file is a no-op (no snapshot, no rewrite). A
|
||||||
|
// file that pre-existed history enablement is lazy-seeded: its current
|
||||||
|
// bytes are captured as an origin snapshot (stamped with the file's mtime,
|
||||||
|
// author "unknown") before the new one. The snapshot is written BEFORE the
|
||||||
|
// live file so a crash can't lose a version the live write superseded.
|
||||||
func WriteTextWithHistory(abs string, body []byte, principalEmail string) error {
|
func WriteTextWithHistory(abs string, body []byte, principalEmail string) error {
|
||||||
histDir := mdHistoryDir(abs)
|
histDir := mdHistoryDir(abs)
|
||||||
logPath := filepath.Join(histDir, mdHistoryLogName)
|
|
||||||
ext := filepath.Ext(abs)
|
ext := filepath.Ext(abs)
|
||||||
|
|
||||||
// Prior live content (nil on create).
|
// Prior live content (nil on create).
|
||||||
var prior []byte
|
var prior []byte
|
||||||
priorSha := ""
|
var priorMtime time.Time
|
||||||
priorMtime := ""
|
|
||||||
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
|
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
|
||||||
if data, rerr := os.ReadFile(abs); rerr == nil {
|
if data, rerr := os.ReadFile(abs); rerr == nil {
|
||||||
prior = data
|
prior = data
|
||||||
priorSha = fileETag(prior)
|
priorMtime = info.ModTime().UTC()
|
||||||
priorMtime = info.ModTime().UTC().Format(time.RFC3339Nano)
|
|
||||||
}
|
}
|
||||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
newSha := fileETag(body)
|
// No-op: identical to the live file → nothing to record or rewrite.
|
||||||
if principalEmail == "" {
|
if prior != nil && bytes.Equal(prior, body) {
|
||||||
principalEmail = "anonymous"
|
return nil
|
||||||
}
|
}
|
||||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
||||||
|
|
||||||
if err := os.MkdirAll(histDir, 0o755); err != nil {
|
if err := os.MkdirAll(histDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("mkdir history: %w", err)
|
return fmt.Errorf("mkdir history: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
writeBlob := func(sha string, data []byte) error {
|
// Lazy-seed the pre-history origin version (no snapshots yet but the
|
||||||
blob := filepath.Join(histDir, sha+ext)
|
// file already has content).
|
||||||
if _, err := os.Stat(blob); errors.Is(err, os.ErrNotExist) {
|
if prior != nil && historyDirEmpty(histDir) {
|
||||||
return zddc.WriteAtomic(blob, data)
|
seedAt := priorMtime
|
||||||
|
if seedAt.IsZero() {
|
||||||
|
seedAt = time.Now().UTC()
|
||||||
}
|
}
|
||||||
return nil
|
if err := writeHistorySnapshot(histDir, seedAt, "", ext, prior); err != nil {
|
||||||
}
|
|
||||||
|
|
||||||
logExisted := false
|
|
||||||
if _, err := os.Stat(logPath); err == nil {
|
|
||||||
logExisted = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries []MdHistoryEntry
|
|
||||||
if logExisted {
|
|
||||||
existing, err := readMdLog(logPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
entries = existing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazy-seed: a file that pre-existed history enablement has prior
|
if err := writeHistorySnapshot(histDir, time.Now().UTC(), principalEmail, ext, body); err != nil {
|
||||||
// bytes but no log. Capture that state as the origin version so the
|
return err
|
||||||
// chain isn't missing its start. Authorship is unknown ("").
|
|
||||||
if prior != nil && !logExisted {
|
|
||||||
if err := writeBlob(priorSha, prior); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ts := priorMtime
|
|
||||||
if ts == "" {
|
|
||||||
ts = now
|
|
||||||
}
|
|
||||||
entries = append(entries, MdHistoryEntry{Ts: ts, By: "", Sha: priorSha, Bytes: len(prior)})
|
|
||||||
}
|
|
||||||
|
|
||||||
lastSha := ""
|
|
||||||
if len(entries) > 0 {
|
|
||||||
lastSha = entries[len(entries)-1].Sha
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record this save unless it's a no-op (identical to the head).
|
|
||||||
changed := newSha != lastSha
|
|
||||||
if changed {
|
|
||||||
if err := writeBlob(newSha, body); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
entries = append(entries, MdHistoryEntry{Ts: now, By: principalEmail, Sha: newSha, Prev: priorSha, Bytes: len(body)})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rewrite the log atomically (CIFS-safe; avoids partial-append
|
|
||||||
// corruption) only when something actually changed.
|
|
||||||
if changed || (prior != nil && !logExisted) {
|
|
||||||
if err := writeMdLog(logPath, entries); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return zddc.WriteAtomic(abs, body)
|
return zddc.WriteAtomic(abs, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMdLog(path string) ([]MdHistoryEntry, error) {
|
// writeHistorySnapshot writes data to <histDir>/<ts>-<email><ext>. On a
|
||||||
data, err := os.ReadFile(path)
|
// name collision (same millisecond + author) it steps the timestamp
|
||||||
|
// forward until a free name is found, so each save keeps a distinct file.
|
||||||
|
func writeHistorySnapshot(histDir string, t time.Time, email, ext string, data []byte) error {
|
||||||
|
who := sanitizeForFilename(email)
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
p := filepath.Join(histDir, mdStamp(t)+"-"+who+ext)
|
||||||
|
if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return zddc.WriteAtomic(p, data)
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t = t.Add(time.Millisecond)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("history: no free snapshot name in %s", histDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyDirEmpty reports whether histDir holds no snapshot files (missing
|
||||||
|
// dir counts as empty).
|
||||||
|
func historyDirEmpty(histDir string) bool {
|
||||||
|
entries, err := os.ReadDir(histDir)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMdHistory returns the saved versions of abs, newest first, with
|
||||||
|
// Current set on the version whose bytes match the live file.
|
||||||
|
func ListMdHistory(abs string) ([]MdHistoryEntry, error) {
|
||||||
|
histDir := mdHistoryDir(abs)
|
||||||
|
dirEntries, err := os.ReadDir(histDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, nil
|
return []MdHistoryEntry{}, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var out []MdHistoryEntry
|
out := []MdHistoryEntry{}
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
for _, de := range dirEntries {
|
||||||
line = strings.TrimSpace(line)
|
if de.IsDir() {
|
||||||
if line == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var e MdHistoryEntry
|
ts, by, ok := parseHistoryName(de.Name())
|
||||||
if err := json.Unmarshal([]byte(line), &e); err != nil {
|
if !ok {
|
||||||
continue // skip a malformed line rather than fail the whole read
|
continue
|
||||||
|
}
|
||||||
|
var size int64
|
||||||
|
if info, ierr := de.Info(); ierr == nil {
|
||||||
|
size = info.Size()
|
||||||
|
}
|
||||||
|
out = append(out, MdHistoryEntry{Ts: ts, By: by, ID: de.Name(), Bytes: size})
|
||||||
|
}
|
||||||
|
// Newest first — the id (filename) sorts lexically by its ts prefix.
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||||
|
// Mark the newest snapshot whose bytes match the live file as current.
|
||||||
|
// Size-gate before reading so we don't slurp every version.
|
||||||
|
if live, lerr := os.ReadFile(abs); lerr == nil {
|
||||||
|
for i := range out {
|
||||||
|
if out[i].Bytes != int64(len(live)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if data, rerr := os.ReadFile(filepath.Join(histDir, out[i].ID)); rerr == nil && bytes.Equal(data, live) {
|
||||||
|
out[i].Current = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out = append(out, e)
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeMdLog(path string, entries []MdHistoryEntry) error {
|
// ServeTextHistory dispatches GET <file>?history=... for history-eligible
|
||||||
var sb strings.Builder
|
// text files: `?history=1` (or empty / `list`) returns the version list as
|
||||||
for _, e := range entries {
|
// JSON; `?history=<id>` returns that snapshot's raw bytes. ACL on the live
|
||||||
e.Current = false // never persist the derived flag
|
// file has already been checked by the caller; fsRoot resolves the cascade
|
||||||
b, err := json.Marshal(e)
|
// for the file-type (history_globs) check.
|
||||||
if err != nil {
|
func ServeTextHistory(w http.ResponseWriter, r *http.Request, fsRoot, abs, version string) {
|
||||||
return err
|
if !IsTextHistoryCandidate(fsRoot, abs) {
|
||||||
}
|
|
||||||
sb.Write(b)
|
|
||||||
sb.WriteByte('\n')
|
|
||||||
}
|
|
||||||
return zddc.WriteAtomic(path, []byte(sb.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListMdHistory returns the saved versions of abs, newest first, with
|
|
||||||
// Current set on the version whose content matches the live file.
|
|
||||||
func ListMdHistory(abs string) ([]MdHistoryEntry, error) {
|
|
||||||
logPath := filepath.Join(mdHistoryDir(abs), mdHistoryLogName)
|
|
||||||
entries, err := readMdLog(logPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
liveSha := ""
|
|
||||||
if data, err := os.ReadFile(abs); err == nil {
|
|
||||||
liveSha = fileETag(data)
|
|
||||||
}
|
|
||||||
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Ts > entries[j].Ts })
|
|
||||||
// Mark the newest entry whose content matches the live file as the
|
|
||||||
// current version. A revert reuses an earlier blob, so the same sha
|
|
||||||
// can appear twice — only the most recent save is "current".
|
|
||||||
for i := range entries {
|
|
||||||
if entries[i].Sha == liveSha {
|
|
||||||
entries[i].Current = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeTextHistory dispatches GET <file>?history=... for history-enabled
|
|
||||||
// text files: `?history=1` (or empty / `list`) returns the version list
|
|
||||||
// as JSON; `?history=<sha>` returns that version's raw bytes. ACL on the
|
|
||||||
// live file has already been checked by the caller.
|
|
||||||
func ServeTextHistory(w http.ResponseWriter, r *http.Request, abs, version string) {
|
|
||||||
if !IsTextHistoryCandidate(abs) {
|
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1023,12 +1058,19 @@ func ServeTextHistory(w http.ResponseWriter, r *http.Request, abs, version strin
|
||||||
_ = json.NewEncoder(w).Encode(entries)
|
_ = json.NewEncoder(w).Encode(entries)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !mdVersionIDRe.MatchString(version) {
|
// version is a snapshot filename. Reject anything that could escape the
|
||||||
|
// history dir, then require it to resolve to a file strictly inside it.
|
||||||
|
if version == "." || version == ".." || strings.ContainsAny(version, "/\\") || strings.Contains(version, "..") {
|
||||||
http.Error(w, "Bad Request — invalid version id", http.StatusBadRequest)
|
http.Error(w, "Bad Request — invalid version id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
blob := filepath.Join(mdHistoryDir(abs), version+filepath.Ext(abs))
|
histDir := mdHistoryDir(abs)
|
||||||
data, err := os.ReadFile(blob)
|
snap := filepath.Join(histDir, version)
|
||||||
|
if snap != filepath.Clean(snap) || !strings.HasPrefix(snap, histDir+string(filepath.Separator)) {
|
||||||
|
http.Error(w, "Bad Request — invalid version id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(snap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// No history dir yet (create only).
|
// No history dir yet (create only).
|
||||||
histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history")
|
histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history")
|
||||||
if _, err := os.Stat(histDir); !os.IsNotExist(err) {
|
if _, err := os.Stat(histDir); !os.IsNotExist(err) {
|
||||||
t.Errorf(".history/ should not exist after create-only; got err=%v", err)
|
t.Errorf(".history/ should not exist after create-only; got err=%v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -149,7 +149,7 @@ func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// .history/ACM-PRJ-EL-SPC-0001/ has exactly one entry (the v1 bytes).
|
// .history/ACM-PRJ-EL-SPC-0001/ has exactly one entry (the v1 bytes).
|
||||||
histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history", "ACM-PRJ-EL-SPC-0001")
|
histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history", "ACM-PRJ-EL-SPC-0001")
|
||||||
ents, err := os.ReadDir(histDir)
|
ents, err := os.ReadDir(histDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read history dir: %v", err)
|
t.Fatalf("read history dir: %v", err)
|
||||||
|
|
@ -186,7 +186,7 @@ func TestRecordPut_ConflictPreservesHistory(t *testing.T) {
|
||||||
t.Fatalf("expected 412, got %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 412, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history")
|
histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history")
|
||||||
if _, err := os.Stat(histDir); !os.IsNotExist(err) {
|
if _, err := os.Stat(histDir); !os.IsNotExist(err) {
|
||||||
t.Errorf("history dir should not exist after 412 conflict; got err=%v", err)
|
t.Errorf("history dir should not exist after 412 conflict; got err=%v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -365,11 +365,11 @@ func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// History at archive/0330C1/.history/ssr/, NOT at archive/.history/.
|
// History at archive/0330C1/.history/ssr/, NOT at archive/.history/.
|
||||||
wanted := filepath.Join(cfg.Root, "Project", "archive", "0330C1", ".history", "ssr")
|
wanted := filepath.Join(cfg.Root, "Project", "archive", "0330C1", ".zddc.d", "history", "ssr")
|
||||||
if _, err := os.Stat(wanted); err != nil {
|
if _, err := os.Stat(wanted); err != nil {
|
||||||
t.Fatalf("expected history at %s; err=%v", wanted, err)
|
t.Fatalf("expected history at %s; err=%v", wanted, err)
|
||||||
}
|
}
|
||||||
bad := filepath.Join(cfg.Root, "Project", "archive", ".history")
|
bad := filepath.Join(cfg.Root, "Project", "archive", ".zddc.d", "history")
|
||||||
if _, err := os.Stat(bad); !os.IsNotExist(err) {
|
if _, err := os.Stat(bad); !os.IsNotExist(err) {
|
||||||
t.Errorf("history must NOT live at %s; err=%v", bad, err)
|
t.Errorf("history must NOT live at %s; err=%v", bad, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ func mustNoErr(t *testing.T, err error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func countBlobs(t *testing.T, histDir string) int {
|
// countSnapshots counts the per-save history files in histDir.
|
||||||
|
func countSnapshots(t *testing.T, histDir string) int {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ents, err := os.ReadDir(histDir)
|
ents, err := os.ReadDir(histDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -39,11 +40,9 @@ func countBlobs(t *testing.T, histDir string) int {
|
||||||
func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) {
|
func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
abs := filepath.Join(dir, "notes.md")
|
abs := filepath.Join(dir, "notes.md")
|
||||||
histDir := filepath.Join(dir, ".history", "notes")
|
histDir := filepath.Join(dir, ".zddc.d", "history", "notes")
|
||||||
sha1 := fileETag([]byte("v1"))
|
|
||||||
sha2 := fileETag([]byte("v2"))
|
|
||||||
|
|
||||||
// ── create ──
|
// ── create: one snapshot, authored, current ──
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "alice@x.com"))
|
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "alice@x.com"))
|
||||||
if b, _ := os.ReadFile(abs); string(b) != "v1" {
|
if b, _ := os.ReadFile(abs); string(b) != "v1" {
|
||||||
t.Fatalf("live = %q, want v1", b)
|
t.Fatalf("live = %q, want v1", b)
|
||||||
|
|
@ -56,18 +55,18 @@ func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) {
|
||||||
if entries[0].By != "alice@x.com" {
|
if entries[0].By != "alice@x.com" {
|
||||||
t.Errorf("by = %q, want alice@x.com", entries[0].By)
|
t.Errorf("by = %q, want alice@x.com", entries[0].By)
|
||||||
}
|
}
|
||||||
if entries[0].Sha != sha1 {
|
|
||||||
t.Errorf("sha = %q, want %q", entries[0].Sha, sha1)
|
|
||||||
}
|
|
||||||
if !entries[0].Current {
|
if !entries[0].Current {
|
||||||
t.Errorf("v1 should be current")
|
t.Errorf("v1 should be current")
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(histDir, sha1+".md")); err != nil {
|
if entries[0].ID == "" || !strings.HasSuffix(entries[0].ID, "-alice@x.com.md") {
|
||||||
t.Errorf("v1 blob missing: %v", err)
|
t.Errorf("id = %q, want a <ts>-alice@x.com.md snapshot name", entries[0].ID)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(histDir, entries[0].ID)); err != nil {
|
||||||
|
t.Errorf("v1 snapshot missing: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── update ──
|
// ── update: second snapshot, newest-first, current moves ──
|
||||||
time.Sleep(2 * time.Millisecond) // distinct RFC3339Nano ts for ordering
|
time.Sleep(2 * time.Millisecond) // distinct timestamp for ordering
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com"))
|
mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com"))
|
||||||
if b, _ := os.ReadFile(abs); string(b) != "v2" {
|
if b, _ := os.ReadFile(abs); string(b) != "v2" {
|
||||||
t.Fatalf("live = %q, want v2", b)
|
t.Fatalf("live = %q, want v2", b)
|
||||||
|
|
@ -76,28 +75,20 @@ func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) {
|
||||||
if len(entries) != 2 {
|
if len(entries) != 2 {
|
||||||
t.Fatalf("after update: want 2 entries, got %d", len(entries))
|
t.Fatalf("after update: want 2 entries, got %d", len(entries))
|
||||||
}
|
}
|
||||||
// newest first
|
if entries[0].By != "bob@x.com" || !entries[0].Current {
|
||||||
if entries[0].Sha != sha2 || !entries[0].Current {
|
t.Errorf("head = %+v, want v2 by bob, current", entries[0])
|
||||||
t.Errorf("head = %+v, want v2 current", entries[0])
|
|
||||||
}
|
}
|
||||||
if entries[0].Prev != sha1 {
|
if entries[1].By != "alice@x.com" || entries[1].Current {
|
||||||
t.Errorf("v2.prev = %q, want %q", entries[0].Prev, sha1)
|
t.Errorf("tail = %+v, want v1 by alice, non-current", entries[1])
|
||||||
}
|
|
||||||
if entries[1].Sha != sha1 || entries[1].Current {
|
|
||||||
t.Errorf("tail = %+v, want v1 non-current", entries[1])
|
|
||||||
}
|
|
||||||
if entries[1].By != "alice@x.com" {
|
|
||||||
t.Errorf("v1 author lost: %q", entries[1].By)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── no-op save (identical content) → dedup, no new entry ──
|
// ── no-op save (identical to live) → no new snapshot ──
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com"))
|
mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com"))
|
||||||
entries, _ = ListMdHistory(abs)
|
if n := countSnapshots(t, histDir); n != 2 {
|
||||||
if len(entries) != 2 {
|
t.Fatalf("dedup failed: want 2 snapshots, got %d", n)
|
||||||
t.Fatalf("dedup failed: want 2 entries, got %d", len(entries))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── restore v1 content → new log entry, blob reused ──
|
// ── restore v1 content → a NEW snapshot (every save is its own file) ──
|
||||||
time.Sleep(2 * time.Millisecond)
|
time.Sleep(2 * time.Millisecond)
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "carol@x.com"))
|
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "carol@x.com"))
|
||||||
if b, _ := os.ReadFile(abs); string(b) != "v1" {
|
if b, _ := os.ReadFile(abs); string(b) != "v1" {
|
||||||
|
|
@ -107,17 +98,16 @@ func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) {
|
||||||
if len(entries) != 3 {
|
if len(entries) != 3 {
|
||||||
t.Fatalf("after restore: want 3 entries, got %d", len(entries))
|
t.Fatalf("after restore: want 3 entries, got %d", len(entries))
|
||||||
}
|
}
|
||||||
if entries[0].Sha != sha1 || !entries[0].Current || entries[0].By != "carol@x.com" {
|
if entries[0].By != "carol@x.com" || !entries[0].Current {
|
||||||
t.Errorf("head = %+v, want restored v1 by carol, current", entries[0])
|
t.Errorf("head = %+v, want restored v1 by carol, current", entries[0])
|
||||||
}
|
}
|
||||||
// Only the newest matching entry is current, even though the oldest
|
// Only the newest matching-content entry is current, even though the
|
||||||
// entry has the same sha.
|
// oldest snapshot has the same bytes.
|
||||||
if entries[2].Current {
|
if entries[2].Current {
|
||||||
t.Errorf("oldest v1 entry should not be current: %+v", entries[2])
|
t.Errorf("oldest v1 entry should not be current: %+v", entries[2])
|
||||||
}
|
}
|
||||||
// Content-addressed: only two distinct blobs (v1, v2) despite 3 saves.
|
if n := countSnapshots(t, histDir); n != 3 {
|
||||||
if n := countBlobs(t, histDir); n != 2 {
|
t.Errorf("snapshots = %d, want 3 (one file per save)", n)
|
||||||
t.Errorf("distinct blobs = %d, want 2", n)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +116,7 @@ func TestWriteTextWithHistory_LazySeedPreexisting(t *testing.T) {
|
||||||
abs := filepath.Join(dir, "doc.md")
|
abs := filepath.Join(dir, "doc.md")
|
||||||
// Simulate a file that existed before history was enabled.
|
// Simulate a file that existed before history was enabled.
|
||||||
mustNoErr(t, zddc.WriteAtomic(abs, []byte("legacy")))
|
mustNoErr(t, zddc.WriteAtomic(abs, []byte("legacy")))
|
||||||
|
time.Sleep(2 * time.Millisecond) // keep the seed's mtime stamp < the edit
|
||||||
|
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("edited"), "dave@x.com"))
|
mustNoErr(t, WriteTextWithHistory(abs, []byte("edited"), "dave@x.com"))
|
||||||
|
|
||||||
|
|
@ -135,21 +126,21 @@ func TestWriteTextWithHistory_LazySeedPreexisting(t *testing.T) {
|
||||||
t.Fatalf("lazy-seed: want 2 entries (seeded prior + new), got %d", len(entries))
|
t.Fatalf("lazy-seed: want 2 entries (seeded prior + new), got %d", len(entries))
|
||||||
}
|
}
|
||||||
// newest = the edit; oldest = the seeded legacy version (author unknown)
|
// newest = the edit; oldest = the seeded legacy version (author unknown)
|
||||||
if entries[0].By != "dave@x.com" || entries[0].Sha != fileETag([]byte("edited")) {
|
if entries[0].By != "dave@x.com" {
|
||||||
t.Errorf("head = %+v, want edit by dave", entries[0])
|
t.Errorf("head = %+v, want edit by dave", entries[0])
|
||||||
}
|
}
|
||||||
if entries[1].By != "" || entries[1].Sha != fileETag([]byte("legacy")) {
|
if entries[1].By != "unknown" {
|
||||||
t.Errorf("seed = %+v, want legacy with empty author", entries[1])
|
t.Errorf("seed = %+v, want legacy with 'unknown' author", entries[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteTextWithHistory_EmptyAuthorAnonymous(t *testing.T) {
|
func TestWriteTextWithHistory_EmptyAuthorUnknown(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
abs := filepath.Join(dir, "x.md")
|
abs := filepath.Join(dir, "x.md")
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("a"), ""))
|
mustNoErr(t, WriteTextWithHistory(abs, []byte("a"), ""))
|
||||||
entries, _ := ListMdHistory(abs)
|
entries, _ := ListMdHistory(abs)
|
||||||
if len(entries) != 1 || entries[0].By != "anonymous" {
|
if len(entries) != 1 || entries[0].By != "unknown" {
|
||||||
t.Fatalf("empty author should record anonymous, got %+v", entries)
|
t.Fatalf("empty author should record 'unknown', got %+v", entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,12 +150,11 @@ func TestServeTextHistory_ListAndVersion(t *testing.T) {
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("one"), "a@x.com"))
|
mustNoErr(t, WriteTextWithHistory(abs, []byte("one"), "a@x.com"))
|
||||||
time.Sleep(2 * time.Millisecond)
|
time.Sleep(2 * time.Millisecond)
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("two"), "b@x.com"))
|
mustNoErr(t, WriteTextWithHistory(abs, []byte("two"), "b@x.com"))
|
||||||
sha1 := fileETag([]byte("one"))
|
|
||||||
|
|
||||||
// ── list ──
|
// ── list ──
|
||||||
req := httptest.NewRequest(http.MethodGet, "/page.md?history=1", nil)
|
req := httptest.NewRequest(http.MethodGet, "/page.md?history=1", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeTextHistory(rec, req, abs, "1")
|
ServeTextHistory(rec, req, dir, abs, "1")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("list status = %d", rec.Code)
|
t.Fatalf("list status = %d", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
@ -176,10 +166,11 @@ func TestServeTextHistory_ListAndVersion(t *testing.T) {
|
||||||
t.Fatalf("list = %+v, want 2 newest-first", got)
|
t.Fatalf("list = %+v, want 2 newest-first", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── specific version content ──
|
// ── specific version content (oldest = "one") ──
|
||||||
req = httptest.NewRequest(http.MethodGet, "/page.md?history="+sha1, nil)
|
oldID := got[1].ID
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/page.md?history="+url.QueryEscape(oldID), nil)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
ServeTextHistory(rec, req, abs, sha1)
|
ServeTextHistory(rec, req, dir, abs, oldID)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("version status = %d", rec.Code)
|
t.Fatalf("version status = %d", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
@ -198,10 +189,10 @@ func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) {
|
||||||
// Drop a secret in the parent so a successful traversal would be visible.
|
// Drop a secret in the parent so a successful traversal would be visible.
|
||||||
mustNoErr(t, zddc.WriteAtomic(filepath.Join(dir, "secret"), []byte("TOPSECRET")))
|
mustNoErr(t, zddc.WriteAtomic(filepath.Join(dir, "secret"), []byte("TOPSECRET")))
|
||||||
|
|
||||||
for _, bad := range []string{"../secret", "..%2Fsecret", "abc/def", "ZZZ", "deadbeef.md"} {
|
for _, bad := range []string{"../secret", "..%2Fsecret", "abc/def", "ZZZ", "nope.md"} {
|
||||||
req := httptest.NewRequest(http.MethodGet, "/p.md?history="+url.QueryEscape(bad), nil)
|
req := httptest.NewRequest(http.MethodGet, "/p.md?history="+url.QueryEscape(bad), nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeTextHistory(rec, req, abs, bad)
|
ServeTextHistory(rec, req, dir, abs, bad)
|
||||||
if rec.Code == http.StatusOK {
|
if rec.Code == http.StatusOK {
|
||||||
t.Errorf("version %q unexpectedly served: body=%q", bad, rec.Body.String())
|
t.Errorf("version %q unexpectedly served: body=%q", bad, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -210,12 +201,52 @@ func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-markdown path → 404 (history not applicable).
|
// Non-markdown path → 404 (text history not applicable).
|
||||||
yamlAbs := filepath.Join(dir, "rec.yaml")
|
yamlAbs := filepath.Join(dir, "rec.yaml")
|
||||||
req := httptest.NewRequest(http.MethodGet, "/rec.yaml?history=1", nil)
|
req := httptest.NewRequest(http.MethodGet, "/rec.yaml?history=1", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeTextHistory(rec, req, yamlAbs, "1")
|
ServeTextHistory(rec, req, dir, yamlAbs, "1")
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("non-md status = %d, want 404", rec.Code)
|
t.Errorf("non-md status = %d, want 404", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWriteTextWithHistory_RenameDirOnMove is covered at the handler level
|
||||||
|
// (serveFileMove); here we only assert the snapshot filenames are SMB-safe
|
||||||
|
// (no colons) so they're valid on the Azure Files share.
|
||||||
|
func TestWriteTextWithHistory_SnapshotNamesAreSMBSafe(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
abs := filepath.Join(dir, "n.md")
|
||||||
|
mustNoErr(t, WriteTextWithHistory(abs, []byte("a"), "cwitt@burnsmcd.com"))
|
||||||
|
entries, _ := ListMdHistory(abs)
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("want 1 entry, got %d", len(entries))
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(entries[0].ID, ":\\/") {
|
||||||
|
t.Errorf("snapshot id %q contains a char invalid on SMB", entries[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsTextHistoryCandidate_CascadeGlobs — which file types qualify for
|
||||||
|
// text history is cascade-driven (history_globs), defaulting to *.md.
|
||||||
|
func TestIsTextHistoryCandidate_CascadeGlobs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Default (no .zddc): *.md only.
|
||||||
|
if !IsTextHistoryCandidate(dir, filepath.Join(dir, "a.md")) {
|
||||||
|
t.Errorf("default: .md should be a candidate")
|
||||||
|
}
|
||||||
|
if IsTextHistoryCandidate(dir, filepath.Join(dir, "a.txt")) {
|
||||||
|
t.Errorf("default: .txt should NOT be a candidate")
|
||||||
|
}
|
||||||
|
// Override via .zddc history_globs in a subtree.
|
||||||
|
sub := filepath.Join(dir, "notes")
|
||||||
|
mustNoErr(t, os.MkdirAll(sub, 0o755))
|
||||||
|
mustNoErr(t, zddc.WriteAtomic(filepath.Join(sub, ".zddc"), []byte("history_globs: [\"*.txt\", \"*.md\"]\n")))
|
||||||
|
zddc.InvalidateCache(dir)
|
||||||
|
if !IsTextHistoryCandidate(dir, filepath.Join(sub, "a.txt")) {
|
||||||
|
t.Errorf("override: .txt should be a candidate under history_globs")
|
||||||
|
}
|
||||||
|
if !IsTextHistoryCandidate(dir, filepath.Join(sub, "a.md")) {
|
||||||
|
t.Errorf("override: .md still a candidate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ import (
|
||||||
// resolvePath translates a URL `path=` query (relative to fsRoot, with
|
// resolvePath translates a URL `path=` query (relative to fsRoot, with
|
||||||
// '/' separator and leading '/') into an absolute filesystem path. It
|
// '/' separator and leading '/') into an absolute filesystem path. It
|
||||||
// rejects path traversal and any segment beginning with '.' or '_' so
|
// rejects path traversal and any segment beginning with '.' or '_' so
|
||||||
// reserved namespaces (e.g. .devshell) cannot be addressed through
|
// reserved namespaces (e.g. the .zddc.d/ bookkeeping sidecar) cannot be
|
||||||
// admin APIs. Returns the cleaned absolute path or an error suitable
|
// addressed through admin APIs. Returns the cleaned absolute path or an
|
||||||
// for a 404.
|
// error suitable for a 404.
|
||||||
func resolvePath(fsRoot, urlPath string) (string, error) {
|
func resolvePath(fsRoot, urlPath string) (string, error) {
|
||||||
urlPath = strings.TrimSpace(urlPath)
|
urlPath = strings.TrimSpace(urlPath)
|
||||||
if urlPath == "" {
|
if urlPath == "" {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ func TestFromDirEntriesFiltersHidden(t *testing.T) {
|
||||||
"Project-A",
|
"Project-A",
|
||||||
"Project-B",
|
"Project-B",
|
||||||
".zddc", // hidden file
|
".zddc", // hidden file
|
||||||
".devshell", // hidden dir
|
".zddc.d", // hidden dir (reserved bookkeeping)
|
||||||
"_template", // scaffolding dir
|
"_template", // scaffolding dir
|
||||||
"_archive", // scaffolding dir
|
"_archive", // scaffolding dir
|
||||||
"_notes.txt", // scaffolding file
|
"_notes.txt", // scaffolding file
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,23 @@ func (chain PolicyChain) EffectiveHistory() bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EffectiveHistoryGlobs returns the basename globs selecting which files
|
||||||
|
// get text edit-history (deepest non-empty wins, then embedded defaults,
|
||||||
|
// then the built-in default ["*.md"]). Independent of EffectiveHistory:
|
||||||
|
// this says WHICH file types qualify; the bool gates whether snapshots are
|
||||||
|
// actually recorded.
|
||||||
|
func (chain PolicyChain) EffectiveHistoryGlobs() []string {
|
||||||
|
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
||||||
|
if g := chain.Levels[i].HistoryGlobs; len(g) > 0 {
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g := chain.Embedded.HistoryGlobs; len(g) > 0 {
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
return []string{"*.md"}
|
||||||
|
}
|
||||||
|
|
||||||
// policyCache caches effective policies keyed by dirPath.
|
// policyCache caches effective policies keyed by dirPath.
|
||||||
// Values are PolicyChain.
|
// Values are PolicyChain.
|
||||||
var policyCache sync.Map
|
var policyCache sync.Map
|
||||||
|
|
@ -390,6 +407,7 @@ func nonZeroZddcFields(zf ZddcFile) []string {
|
||||||
add("virtual", zf.Virtual != nil)
|
add("virtual", zf.Virtual != nil)
|
||||||
add("drop_target", zf.DropTarget != nil)
|
add("drop_target", zf.DropTarget != nil)
|
||||||
add("history", zf.History != nil)
|
add("history", zf.History != nil)
|
||||||
|
add("history_globs", len(zf.HistoryGlobs) > 0)
|
||||||
add("worm", zf.Worm != nil)
|
add("worm", zf.Worm != nil)
|
||||||
add("available_tools", len(zf.AvailableTools) > 0)
|
add("available_tools", len(zf.AvailableTools) > 0)
|
||||||
add("received_path", zf.ReceivedPath != "")
|
add("received_path", zf.ReceivedPath != "")
|
||||||
|
|
|
||||||
|
|
@ -199,10 +199,15 @@ paths:
|
||||||
default_tool: tables
|
default_tool: tables
|
||||||
available_tools: [tables]
|
available_tools: [tables]
|
||||||
virtual: true
|
virtual: true
|
||||||
|
# Edit-history default-on for the deliverables list (subtree-
|
||||||
|
# inheriting; see working/ note). Operators override per .zddc.
|
||||||
|
history: true
|
||||||
rsk:
|
rsk:
|
||||||
default_tool: tables
|
default_tool: tables
|
||||||
available_tools: [tables]
|
available_tools: [tables]
|
||||||
virtual: true
|
virtual: true
|
||||||
|
# Edit-history default-on for the risk register.
|
||||||
|
history: true
|
||||||
working:
|
working:
|
||||||
default_tool: browse
|
default_tool: browse
|
||||||
available_tools: [browse]
|
available_tools: [browse]
|
||||||
|
|
@ -213,6 +218,12 @@ paths:
|
||||||
# folder" picker) and lands it at archive/<party>/working/<name>,
|
# folder" picker) and lands it at archive/<party>/working/<name>,
|
||||||
# which carries its own history: true + auto-own convention.
|
# which carries its own history: true + auto-own convention.
|
||||||
virtual: true
|
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:
|
staging:
|
||||||
default_tool: browse
|
default_tool: browse
|
||||||
available_tools: [browse]
|
available_tools: [browse]
|
||||||
|
|
@ -309,6 +320,10 @@ paths:
|
||||||
# tables tool serves it from the embedded default
|
# tables tool serves it from the embedded default
|
||||||
# spec even when the on-disk folder doesn't exist.
|
# spec even when the on-disk folder doesn't exist.
|
||||||
virtual: true
|
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
|
# MDL records: each .yaml file is an independent
|
||||||
# deliverable with its own composed tracking number.
|
# deliverable with its own composed tracking number.
|
||||||
# No type lock — the row's body fields drive the
|
# No type lock — the row's body fields drive the
|
||||||
|
|
@ -348,6 +363,8 @@ paths:
|
||||||
# as mdl/. Embedded default-rsk spec backs it when no
|
# as mdl/. Embedded default-rsk spec backs it when no
|
||||||
# operator override is on disk.
|
# operator override is on disk.
|
||||||
virtual: true
|
virtual: true
|
||||||
|
# Edit-history default-on (same as mdl/).
|
||||||
|
history: true
|
||||||
# RSK records: each .yaml file is a row of a parent
|
# RSK records: each .yaml file is a row of a parent
|
||||||
# rsk-type deliverable. The table itself has a tracking
|
# rsk-type deliverable. The table itself has a tracking
|
||||||
# number (same default components as an MDL deliverable
|
# number (same default components as an MDL deliverable
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,7 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
|
||||||
}
|
}
|
||||||
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
||||||
seg := strings.ToLower(parts[3])
|
seg := strings.ToLower(parts[3])
|
||||||
switch seg {
|
if IsPerPartySlot(seg) {
|
||||||
case "mdl", "rsk", "incoming", "received", "issued",
|
|
||||||
"working", "staging", "reviewing":
|
|
||||||
if err := resolveAt(3, seg); err != nil {
|
if err := resolveAt(3, seg); err != nil {
|
||||||
return target, err
|
return target, err
|
||||||
}
|
}
|
||||||
|
|
@ -117,11 +115,8 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
// caller writing under them bypassed the virtual resolver; the
|
// caller writing under them bypassed the virtual resolver; the
|
||||||
// content belongs under archive/<party>/<slot>/ (browse's "New
|
// content belongs under archive/<party>/<slot>/ (browse's "New
|
||||||
// folder" picker prompts for the party).
|
// folder" picker prompts for the party).
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 && IsVirtualAggregatorSlot(strings.ToLower(parts[1])) {
|
||||||
switch 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])
|
||||||
case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
|
|
||||||
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))
|
resolvedSegs := make([]string, len(parts))
|
||||||
|
|
@ -180,9 +175,7 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
// "archive".
|
// "archive".
|
||||||
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
|
||||||
seg := strings.ToLower(parts[3])
|
seg := strings.ToLower(parts[3])
|
||||||
switch seg {
|
if IsPerPartySlot(seg) {
|
||||||
case "mdl", "rsk", "incoming", "received", "issued",
|
|
||||||
"working", "staging", "reviewing":
|
|
||||||
if err := resolveAt(3, seg); err != nil {
|
if err := resolveAt(3, seg); err != nil {
|
||||||
return target, err
|
return target, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,13 @@ type ZddcFile struct {
|
||||||
// by PolicyChain.EffectiveHistory. Empty (nil) inherits via cascade.
|
// by PolicyChain.EffectiveHistory. Empty (nil) inherits via cascade.
|
||||||
History *bool `yaml:"history,omitempty" json:"history,omitempty"`
|
History *bool `yaml:"history,omitempty" json:"history,omitempty"`
|
||||||
|
|
||||||
|
// HistoryGlobs selects WHICH files get text edit-history by basename
|
||||||
|
// glob (e.g. ["*.md", "*.txt"]). The History flag gates whether
|
||||||
|
// snapshots are recorded; this says which file types qualify.
|
||||||
|
// Subtree behavior, deepest non-empty wins (PolicyChain.
|
||||||
|
// EffectiveHistoryGlobs); defaults to ["*.md"] when unset.
|
||||||
|
HistoryGlobs []string `yaml:"history_globs,omitempty" json:"history_globs,omitempty"`
|
||||||
|
|
||||||
// Worm marks this directory (and its descendants) as
|
// Worm marks this directory (and its descendants) as
|
||||||
// write-once-read-many. A non-nil Worm list — even an empty one —
|
// write-once-read-many. A non-nil Worm list — even an empty one —
|
||||||
// puts the path into a WORM zone with these effects, applied AFTER
|
// puts the path into a WORM zone with these effects, applied AFTER
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,16 @@ func HistoryAt(fsRoot, dirPath string) bool {
|
||||||
return chain.EffectiveHistory()
|
return chain.EffectiveHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HistoryGlobsAt returns the effective history file-type globs at dirPath
|
||||||
|
// (default ["*.md"]). See PolicyChain.EffectiveHistoryGlobs.
|
||||||
|
func HistoryGlobsAt(fsRoot, dirPath string) []string {
|
||||||
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||||
|
if err != nil {
|
||||||
|
return []string{"*.md"}
|
||||||
|
}
|
||||||
|
return chain.EffectiveHistoryGlobs()
|
||||||
|
}
|
||||||
|
|
||||||
// IsDeclaredPath reports whether dirPath is mentioned in the
|
// IsDeclaredPath reports whether dirPath is mentioned in the
|
||||||
// cascade — either by an on-disk .zddc at that level OR by any
|
// cascade — either by an on-disk .zddc at that level OR by any
|
||||||
// ancestor's paths: tree (including the embedded defaults).
|
// ancestor's paths: tree (including the embedded defaults).
|
||||||
|
|
@ -284,12 +294,8 @@ func CanonicalFolderAt(fsRoot, dirPath string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// <project>/archive/<party>/<folder>
|
// <project>/archive/<party>/<folder>
|
||||||
if len(segs) == 4 && segs[1] == "archive" {
|
if len(segs) == 4 && segs[1] == "archive" && IsPerPartySlot(segs[3]) {
|
||||||
switch segs[3] {
|
return segs[3]
|
||||||
case "incoming", "received", "issued", "mdl", "rsk",
|
|
||||||
"working", "staging", "reviewing":
|
|
||||||
return segs[3]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,17 +61,22 @@ func TestHistoryAt_Defaults(t *testing.T) {
|
||||||
path string
|
path string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
// Project-level working/ is a pure virtual aggregator — no
|
// Edit-history defaults on for the three live-editing slots:
|
||||||
// direct content, so no history there.
|
// working, mdl, rsk — at both the project-level virtual nodes and
|
||||||
{filepath.Join(root, "Project-X", "working"), false},
|
// the per-party folders (subtree-inheriting).
|
||||||
// Per-party working carries history (edit-history versioning).
|
{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"), true},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), 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", "working", "alice@example.com", "notes"), true},
|
||||||
// Sibling slots get no history.
|
{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", "staging"), false},
|
||||||
{filepath.Join(root, "Project-X", "reviewing"), false},
|
{filepath.Join(root, "Project-X", "reviewing"), false},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false},
|
{filepath.Join(root, "Project-X", "ssr"), false},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), 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", "staging"), false},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ func scanFixture(t *testing.T) string {
|
||||||
mk("ProjectA/Sub/.zddc", "title: A-sub\n")
|
mk("ProjectA/Sub/.zddc", "title: A-sub\n")
|
||||||
mk("ProjectB/.zddc", "title: B\n")
|
mk("ProjectB/.zddc", "title: B\n")
|
||||||
// Reserved-prefix subtrees must be pruned.
|
// Reserved-prefix subtrees must be pruned.
|
||||||
mk(".devshell/.zddc", "title: hidden\n")
|
mk(".zddc.d/.zddc", "title: hidden\n")
|
||||||
mk("_template/.zddc", "title: scaffold\n")
|
mk("_template/.zddc", "title: scaffold\n")
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
zddc/internal/zddc/slots.go
Normal file
61
zddc/internal/zddc/slots.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package zddc
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Canonical project slots — the fixed lifecycle shape of a project.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
var (
|
||||||
|
// rowSlots: project-level tables rollups (ssr) + the per-party record
|
||||||
|
// folders they aggregate (mdl, rsk).
|
||||||
|
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"}
|
||||||
|
)
|
||||||
|
|
||||||
|
func slotIn(set []string, s string) bool {
|
||||||
|
for _, v := range set {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRowSlot reports whether slot is a tables-rollup slot (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>/.
|
||||||
|
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...), "|")
|
||||||
|
}
|
||||||
|
|
@ -162,7 +162,7 @@ type VirtualViewResolution struct {
|
||||||
// virtualViewRE matches /<project>/<slot>[/<rest>] where slot is one
|
// virtualViewRE matches /<project>/<slot>[/<rest>] where slot is one
|
||||||
// of the canonical virtual view names. Capture 1 = project, capture
|
// of the canonical virtual view names. Capture 1 = project, capture
|
||||||
// 2 = slot, capture 3 = rest (may be empty).
|
// 2 = slot, capture 3 = rest (may be empty).
|
||||||
var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(ssr|mdl|rsk|working|staging|reviewing)(?:/(.*))?$`)
|
var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(` + virtualAggregatorAlternation() + `)(?:/(.*))?$`)
|
||||||
|
|
||||||
// partyNameRE matches the SSR schema's `name` pattern. Same regex
|
// partyNameRE matches the SSR schema's `name` pattern. Same regex
|
||||||
// used at row-resolution time so URLs with invalid party tokens fail
|
// used at row-resolution time so URLs with invalid party tokens fail
|
||||||
|
|
@ -176,15 +176,8 @@ func ValidPartyName(s string) bool {
|
||||||
return partyNameRE.MatchString(s)
|
return partyNameRE.MatchString(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsFolderNavSlot reports whether slot is one of the folder-nav
|
// IsFolderNavSlot / IsRowSlot / IsVirtualAggregatorSlot / IsPerPartySlot
|
||||||
// lifecycle slots (working, staging, reviewing).
|
// live in slots.go (the single canonical-slot registry).
|
||||||
func IsFolderNavSlot(slot string) bool {
|
|
||||||
switch slot {
|
|
||||||
case "working", "staging", "reviewing":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// planReviewURLRE matches /<project>/archive/<party>/received/<tracking>/
|
// planReviewURLRE matches /<project>/archive/<party>/received/<tracking>/
|
||||||
// — the only URL shape Plan Review accepts. Trailing slash optional.
|
// — the only URL shape Plan Review accepts. Trailing slash optional.
|
||||||
|
|
@ -203,16 +196,6 @@ func IsPlanReviewURL(urlPath string) bool {
|
||||||
return planReviewURLRE.MatchString(urlPath)
|
return planReviewURLRE.MatchString(urlPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsRowSlot reports whether slot is one of the tables-rollup slots
|
|
||||||
// (ssr, mdl, rsk).
|
|
||||||
func IsRowSlot(slot string) bool {
|
|
||||||
switch slot {
|
|
||||||
case "ssr", "mdl", "rsk":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveVirtualView inspects urlPath and returns a populated
|
// ResolveVirtualView inspects urlPath and returns a populated
|
||||||
// resolution iff the URL targets one of the project-level virtual
|
// resolution iff the URL targets one of the project-level virtual
|
||||||
// views (ssr/, mdl/, rsk/, working/, staging/, reviewing/).
|
// views (ssr/, mdl/, rsk/, working/, staging/, reviewing/).
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
||||||
if top.History != nil {
|
if top.History != nil {
|
||||||
out.History = top.History
|
out.History = top.History
|
||||||
}
|
}
|
||||||
|
if len(top.HistoryGlobs) > 0 {
|
||||||
|
out.HistoryGlobs = top.HistoryGlobs
|
||||||
|
}
|
||||||
// Worm: presence (non-nil, even empty) marks the WORM zone.
|
// Worm: presence (non-nil, even empty) marks the WORM zone.
|
||||||
// Concat-dedupe across levels (a deeper .zddc adds controllers);
|
// Concat-dedupe across levels (a deeper .zddc adds controllers);
|
||||||
// preserve a non-nil empty slice so `worm: []` survives the
|
// preserve a non-nil empty slice so `worm: []` survives the
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue