refactor(history): store under .zddc.d/history/; drop .history carve-out + dead .devshell

Consolidate edit-history bookkeeping under the single reserved .zddc.d/
sidecar (where tokens + access logs already live), instead of its own
top-level .history/ dot-name:

- history.go: record + text history now write/read <dir>/.zddc.d/history/<stem>/
  (was <dir>/.history/<stem>/). Const renamed .history → .zddc.d/history and
  unexported (the only external user was the dispatch carve-out). The history
  VIEWER endpoints (<record>.yaml?history=1, <file>?history=…) read it
  server-side, so they keep working for anyone with read on the live file;
  the raw store is bookkeeping, blocked by the existing dot-prefix guard.
- main.go: drop the .history GET carve-out (b9ebee7) — superseded; history is
  reached via the viewer, not raw browsing. Reword the guard comment to
  "reserve .zddc.d/ bookkeeping" (Part B will replace the blanket block with a
  .zddc.d/ admin-fence).
- Delete dead .devshell references (the dev-shell was dropped from the chart):
  guard comment, paths.go comment, test fixtures/cases (→ .zddc.d), and docs.

This is Part A of the approved plan: ship history in its permanent home so we
never migrate it twice. Tests updated to the new paths; the obsolete
TestDispatchHistoryReadCarveOut is removed (raw-block covered by
TestDispatchHidesDotPrefixedSegments, viewer by mdhistory_test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-02 13:48:41 -05:00
parent 1eeaa1bd96
commit e4e0fedaa2
12 changed files with 62 additions and 115 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -825,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
@ -866,16 +869,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
if seg == handler.ZddcFileBasename && i == len(segments)-1 { if seg == handler.ZddcFileBasename && i == len(segments)-1 {
continue continue
} }
// `.history/` is ACL-modeled edit-history content, not
// infrastructure: it inherits the same .zddc chain as the file it
// shadows, so reads need no guard beyond permissions (if you can
// read the live file you can read its history). Carve GET/HEAD
// through to the ACL-gated file serve; writes stay blocked (the
// file API has its own segment check). The listing dot-filter
// still keeps it out of default views unless ?hidden is set.
if seg == handler.HistoryDirName && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
continue
}
if hiddenOK { if hiddenOK {
continue continue
} }

View file

@ -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)
} }
} }
@ -1066,52 +1064,8 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
}) })
} }
// TestDispatchHistoryReadCarveOut — .history/ snapshots are readable via // (TestDispatchHistoryReadCarveOut was removed: history snapshots now live
// GET/HEAD (ACL-gated, like the files they shadow), but writes into // under the reserved .zddc.d/history/ namespace — blocked raw by the
// .history/ and reads of genuine infra (.devshell) stay blocked. // dot-prefix guard, like any bookkeeping, and surfaced only through the
func TestDispatchHistoryReadCarveOut(t *testing.T) { // history endpoints. Raw-block coverage is in TestDispatchHidesDotPrefixedSegments;
root := t.TempDir() // the viewer is covered in mdhistory_test.go.)
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": rwcd\n")
snapDir := filepath.Join(root, "Proj", "docs", ".history", "notes")
mustMkdir(t, snapDir)
mustWrite(t, filepath.Join(snapDir, "20260101T000000.000Z-a@x.com.md"), "OLD VERSION")
mustMkdir(t, filepath.Join(root, ".devshell"))
mustWrite(t, filepath.Join(root, ".devshell", "secret"), "TOPSECRET")
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"}
ring := handler.NewLogRing(10)
withEmail := func(req *http.Request) *http.Request {
return req.WithContext(handler.WithEmail(req.Context(), "u@x.com"))
}
// GET a snapshot → served (carve-out + ACL read).
req := withEmail(httptest.NewRequest(http.MethodGet, "/Proj/docs/.history/notes/20260101T000000.000Z-a@x.com.md", nil))
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != "OLD VERSION" {
t.Fatalf(".history GET: code=%d body=%q, want 200 OLD VERSION", rec.Code, rec.Body.String())
}
// PUT into .history → blocked (carve-out is GET/HEAD only).
req = withEmail(httptest.NewRequest(http.MethodPut, "/Proj/docs/.history/notes/x.md", strings.NewReader("nope")))
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code == http.StatusOK || rec.Code == http.StatusCreated {
t.Errorf(".history PUT should be blocked, got %d", rec.Code)
}
if _, err := os.Stat(filepath.Join(snapDir, "x.md")); err == nil {
t.Errorf(".history PUT must not create a file")
}
// .devshell stays blocked.
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, withEmail(httptest.NewRequest(http.MethodGet, "/.devshell/secret", nil)))
if rec.Code != http.StatusNotFound {
t.Errorf(".devshell GET: code=%d, want 404", rec.Code)
}
}

View file

@ -759,7 +759,7 @@ func TestFileAPI_MoveRenamesHistoryDir(t *testing.T) {
"Docs/notes.md": "# notes\n", "Docs/notes.md": "# notes\n",
}) })
// Pre-seed a history snapshot for notes.md. // Pre-seed a history snapshot for notes.md.
histOld := filepath.Join(root, "Docs", ".history", "notes") histOld := filepath.Join(root, "Docs", ".zddc.d", "history", "notes")
if err := os.MkdirAll(histOld, 0o755); err != nil { if err := os.MkdirAll(histOld, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -775,7 +775,7 @@ func TestFileAPI_MoveRenamesHistoryDir(t *testing.T) {
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("rename: want 200, got %d: %s", rec.Code, rec.Body.String()) t.Fatalf("rename: want 200, got %d: %s", rec.Code, rec.Body.String())
} }
if _, err := os.Stat(filepath.Join(root, "Docs", ".history", "renamed")); err != nil { if _, err := os.Stat(filepath.Join(root, "Docs", ".zddc.d", "history", "renamed")); err != nil {
t.Errorf("history dir not renamed to <newstem>: %v", err) t.Errorf("history dir not renamed to <newstem>: %v", err)
} }
if _, err := os.Stat(histOld); !os.IsNotExist(err) { if _, err := os.Stat(histOld); !os.IsNotExist(err) {
@ -793,10 +793,10 @@ func TestFileAPI_MoveRenamesHistoryDir(t *testing.T) {
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("cross-dir move: want 200, got %d: %s", rec.Code, rec.Body.String()) t.Fatalf("cross-dir move: want 200, got %d: %s", rec.Code, rec.Body.String())
} }
if _, err := os.Stat(filepath.Join(root, "Docs", ".history", "renamed")); err != nil { 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) t.Errorf("history should stay behind on a cross-dir move: %v", err)
} }
if _, err := os.Stat(filepath.Join(root, "Other", ".history", "renamed")); !os.IsNotExist(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) t.Errorf("history should NOT follow a cross-dir move; err=%v", err)
} }
} }

View file

@ -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.
@ -60,14 +60,14 @@ const (
auditFieldPreviousSha = "previous_sha" auditFieldPreviousSha = "previous_sha"
) )
// HistoryDirName is the dot-prefixed history folder under each // historyDirRel is where edit-history snapshots live under each
// history-tracked directory. WRITES are server-only — the file API's // history-tracked directory: <dir>/.zddc.d/history/<stem>/. It sits under
// segment check rejects client PUT/DELETE/POST into it. READS (GET/HEAD) // the single reserved .zddc.d/ sidecar namespace (all zddc bookkeeping), so
// are carved out of the dispatcher's dot-prefix guard so snapshots are // it is reached only through the history endpoints (records:
// fetchable as ordinary ACL-gated content (the .history subtree inherits // <record>.yaml?history=1; text: <file>?history=…), which read it
// the same .zddc chain as the files it shadows); the listing dot-filter // server-side — never as raw browsable content. Both the record and text
// still keeps it out of default views unless ?hidden is set. // history paths share this base.
const HistoryDirName = ".history" 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.
@ -265,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)
} }
@ -613,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) {
@ -637,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)
@ -860,7 +860,7 @@ func matchHistoryGlobs(globs []string, base string) bool {
} }
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)))
} }
// mdStamp renders t as the colon-free snapshot timestamp with the trailing // mdStamp renders t as the colon-free snapshot timestamp with the trailing

View file

@ -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)
} }

View file

@ -40,7 +40,7 @@ func countSnapshots(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")
// ── create: one snapshot, authored, current ── // ── create: one snapshot, authored, current ──
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "alice@x.com")) mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "alice@x.com"))

View file

@ -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 == "" {

View file

@ -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

View file

@ -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
} }