Commit graph

12 commits

Author SHA1 Message Date
2d114fcb96 refactor: unified listing protocol + form-editor retirement + admin elevation
Three coordinated changes that share the same files. Common theme:
convention beats exception. Where the codebase had a bespoke wire shape
or a special-case route, replace it with the generic shape every other
client already speaks.

== Listing protocol ==

GET / Accept: application/json used to dispatch to a bespoke
ServeProjectList handler returning {name, url, title} per project — a
shape that diverged from every other directory's listing.FileInfo
response. Now:

- listing.FileInfo gains an optional `title` field (read from each
  directory's own .zddc title:). Generic clients (landing, browse)
  read the same shape from every URL.
- appfs.ListDirectory emits a virtual `.zddc` entry (is_dir:false,
  virtual:true) when no on-disk file exists at that path and the
  caller asked for ?hidden=1. Opens an editable view of the cascade
  defaults; PUT-saving its bytes materialises a real file.
- The bespoke GET / JSON branch in cmd/zddc-server/main.go is gone.
  The bare-root landing serve is Accept-gated: HTML requests get the
  landing tool (project picker), JSON requests fall through to
  ServeDirectory and get the generic listing.
- landing's fetchProjects filters the new generic shape (is_dir,
  strip trailing slash) — same pattern fetchParties already used at
  /<project>/archive/.

== Form editor retirement ==

`<dir>/.zddc.html` was a server-rendered form for editing per-directory
.zddc files (~900 LOC across zddceditor.go, zddchandler.go, zddc_assets.go).
Browse's YAML/CodeMirror editor (with .zddc-schema lint) already edits
the same files via the generic file-API. Two ways to edit the same data
is exception, not convention.

- Delete zddceditor.go, zddchandler.go, zddc_assets.go and tests.
- `/<dir>/.zddc.html` → 302 redirect to `/<dir>/?file=.zddc` (browse
  opens the .zddc in its editor pane).
- /.profile/zddc/* namespace deleted (REST API + assets sub-route).
- Profile page's "Editable .zddc files" list links to browse.
- ServeZddcFile's 405 message + virtual-body comment point at the
  browse URL instead of the dead form.

== Admin elevation (Principal model) ==

Sudo-style: admins are treated as normal users by default; opting into
admin powers is per-request and gated by a `zddc-elevate=1` cookie.

- zddc.Principal{Email, Elevated} replaces bare-email arguments on
  IsAdmin / IsSubtreeAdmin / CanEditZddc. The signature change makes
  the elevation gate compiler-enforced at every admin call site —
  audit-fragility is gone. The empty-email short-circuit is no longer
  load-bearing for elevation; Principal.gate() is the explicit check.
- handler.ACLMiddleware derives Elevated per request: bearer tokens
  are implicitly elevated (CLI clients can't toggle a cookie); browser
  sessions elevate only when zddc-elevate=1 is set. PrincipalFromContext(r)
  is the one-call-per-site bundling helper.
- Every admin-check call site updated to pass a Principal.
- /.auth/admin (forward_auth target for the dev-shell IDE) explicitly
  bypasses elevation with a synthetic-elevated Principal — different
  cookie scope than zddc-server origin, documented inline.
- AccessView gains CanElevate (elevation-independent "does this email
  have admin authority anywhere?") so the header toggle can render
  itself for an un-elevated admin who hasn't opted in yet.
- ServeProjectList is removed; ProjectInfo + EnumerateProjects stay
  for the profile page's server-rendered project list.
- MatchAppHTML stays — still used by main.go to route <dir>/<tool>.html
  URLs to the apps subsystem when no real file exists.
- Test helpers carry Elevated=true by default (matches the
  pre-elevation default; tests for the un-elevated gate use the
  explicit form).

Go tests pass across all 14 internal packages. Browse + every other
tool rebuilds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:15:07 -05:00
72c0552750 feat(browse): "Show hidden" toggle — list .-prefixed and _-prefixed entries
Adds a UI checkbox next to the existing Sort dropdown that surfaces
hidden entries when ACL would otherwise allow read. Default off
(matches today's filtered behavior). On toggle, browse re-fetches
the current directory with ?hidden=1 and re-renders.

  ┌─ browse toolbar ─────────────────────────────────────────────┐
  │  Sort: [Name (A→Z) ▾]    ☐ Show hidden                       │
  └──────────────────────────────────────────────────────────────┘

Server-side surface:

  - internal/fs/tree.go ListDirectory gains an `includeHidden bool`
    parameter. The .-prefix filter (previously hard-coded) now also
    drops _-prefix entries (matches dispatch's reserved-prefix guard)
    and honors the new flag.
  - internal/handler/directory.go reads `?hidden=1` from the request
    and threads it through.
  - cmd/zddc-server/main.go dispatcher relaxes its dot-prefix and
    _-prefix guards for GET/HEAD when `?hidden=1` is set, so clicking
    a hidden entry's link works. `_app/` (apps cache) stays
    unconditionally reserved — those bytes must go through the apps
    resolver. Writes to hidden paths stay blocked (the file API has
    its own segment check that the flag does NOT relax).
  - internal/listing/listing.go: signature parity (the lower-level
    helper that's used by tests + non-cascade listing paths).

Security model unchanged: the ACL chain on the parent dir is the only
real gate. Whoever can read the dir can see its contents — toggling
"Show hidden" just stops the client-side filter from masking
.-prefixed and _-prefixed entries. Hidden paths today:

  • <dir>/.zddc                ACL YAML — already exposed via /.profile/zddc
  • <dir>/.converted/<base>    cached MD→DOCX/HTML/PDF, same sensitivity as source
  • <root>/.zddc.d/tokens/     per-token metadata; filename = sha256(token)
                               so not bearer-usable. Default root ACL
                               restricts to admins; matches /.tokens UI.
  • <root>/.zddc.d/logs/       access logs; same admins-only audience
  • <root>/_app/               cached upstream tool HTML (public)
  • <root>/_template/          install.zip scaffolding (public)

None of these contain bearer credentials or secret material that the
existing ACL doesn't already gate. The walls are still the cascade.
2026-05-13 14:45:41 -05:00
9c7858c60a feat(zddc): Phase 4c — stage strip driven by cascade-declared children
The shared/nav.js stage strip previously hardcoded four stages
(archive/working/staging/reviewing) with their labels and target
URLs baked into the file. Operators couldn't add a fifth stage or
rename "Working" to "In-Progress" without forking shared code.

Now cascade-driven end-to-end:

  Server-side:
    listing.FileInfo gains a Declared bool field. fs.ListDirectory
    stamps Declared=true on every entry whose name matches the
    cascade's ChildrenDeclaredAt(parent) — both real on-disk dirs
    and virtual canonical injections. Bugfix in the same patch:
    virtualCanonicalFolders was passing the relative dirPath to
    ChildrenDeclaredAt (which expects absolute); now passes absDir.

  Client-side:
    shared/nav.js fetches the project root's JSON listing on
    DOMContentLoaded, filters to declared+is_dir entries, sorts by
    canonical workflow order (archive → working → staging →
    reviewing, then any extras alphabetically), and renders the
    strip. Labels read e.display_name → falls back to titleCase(name).

    Hardcoded FALLBACK_STAGES kicks in only on fetch failure
    (offline / file:// / non-zddc-server backend). Rendered
    immediately so the strip appears without flicker, then the
    cascade-fetched list replaces it once available.

  Effect:
    Project-3 (which has display: { archive: "Records",
    working: "In-Progress", ... } in its .zddc) now shows
    "Records · In-Progress · Outbox · Pending Responses" in every
    tool's strip. Project-1 still shows "Archive · Working ·
    Staging · Reviewing". No code change to render either; the
    cascade decides.

Tests:
  - tests/nav.spec.js relies on the mock server returning HTML at
    every URL, so the fetch fails over to fallback stages — the
    test renders the same Archive/Working/Staging/Reviewing labels
    it always did, with no test changes needed.
  - All 248 Playwright + all Go tests green.

Remaining client-side hardcode: archive/js/source.js +
archive/js/app.js's mode detection. Phase 4d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:34:56 -05:00
5e393cbeaf feat(zddc): Phase 3 completion — all canonical-folder behaviour now cascade-driven
Final consumer migration. The Go-coded lists that previously encoded
the ZDDC convention all defer to the .zddc cascade now.

Schema added:
  available_tools: [tool1, tool2, ...]   concat-union across cascade;
                                          tools not in the union are
                                          denied auto-route at that path
  auto_own_fenced: true|false             generated auto-own .zddc
                                          carries inherit:false (private
                                          to creator)

Lookups added:
  AvailableToolsAt(root, dir)   union of available_tools across cascade
  IsToolAvailableAt(root, dir, tool)
  AutoOwnFencedAt(root, dir)    leaf-only

Cascade semantics finalised (per field):
  default_tool      → leaf→root walk (parent applies to descendants)
  available_tools   → leaf→root union (each level adds; baseline at root)
  auto_own          → leaf-only (creating THIS dir specifically)
  auto_own_fenced   → leaf-only (same)
  virtual           → leaf-only (THIS dir is virtual, not subtree)

Consumers migrated:
  apps.DefaultAppAt        → zddc.DefaultToolAt
  apps.AppAvailableAt      → zddc.IsToolAvailableAt (+ landing special)
  EnsureCanonicalAncestors → AutoOwnAt + AutoOwnFencedAt
  fs.ListDirectory empty-list fallback     → zddc.IsDeclaredPath
  fs.virtualCanonicalFolders               → zddc.ChildrenDeclaredAt
  dispatcher canonical-folder branches     → unified into one
                                              cascade-declared block

Hardcoded helpers REMOVED (dead code):
  apps.inAncestorWithName
  zddc.autoOwnDepthMatch / isAutoOwnDepthMatch

Hardcoded lists kept as data sources for the cascade walker but
no longer drive routing logic:
  ProjectRootFolders / PartyFolders / AutoOwnCanonicalNames /
  VirtualOnlyCanonicalNames / IsProjectRootFolder / IsArchivePartyFolder /
  IsArchivePartyMdlDir — all still defined; only `ProjectRootFolders`
  is used by special.go's IsProjectRootFolder. The rest are dead.

Dispatcher unified: the previously-two branches (per-party folder vs
project-root folder) collapse into one cascade-declared-path block
that handles the slash/no-slash convention uniformly:
  - no-slash, default_tool=tables  → ServeTable (default-MDL fallback)
  - no-slash, default_tool set     → apps.Serve(tool)
  - no-slash, no default_tool      → 302 to slash form
  - slash, any                     → ServeDirectory empty-list fallback

The IsDir branch's switch also un-hardcoded — any cascade tool is
served (not just the legacy 3 names), so e.g. /Project/archive/<party>
/incoming (no slash) now serves classifier directly rather than 302'ing
to the slash form.

defaults.zddc.yaml populated with the canonical convention as the
recipe. Operators edit it (or override per-directory on disk) to
change any behaviour — no Go code changes required.

Browse drag-drop scope (working/staging/incoming) is the one remaining
client-side hardcoded regex; cascading that requires the cascade JSON
to be served to the client, which is its own Phase 4 piece.

Tests updated for the new no-slash mdl URL convention (landing MDL
card test) and no-slash stage URLs (nav strip test). All 248
Playwright + all Go tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:36:33 -05:00
5debd552ae feat: virtual fallback for archive/<party>/* folders + incoming fixture data
Three coupled fixes:

1. landing MDL card: Open button now navigates to /<project>/archive/
   <party>/mdl (no trailing slash) so the tables tool loads. The
   slash form would route to browse instead, which is not what users
   want when they click "Open MDL".

2. zddc-server canonical-folder fallback extended to
   archive/<party>/{mdl,incoming,received,issued}. New
   zddc.IsArchivePartyFolder() recognises any of the four party
   folders at depth 4. fs.ListDirectory returns [] for missing
   on-disk variants (mirroring the project-root behavior added in
   commit 3fc3717); the dispatcher routes slash forms to
   ServeDirectory and the no-slash mdl form to ServeTable, with
   non-mdl no-slash forms 302'ing to the slash form.

   So /Project-N/archive/<party>/incoming/ now lands on an empty
   browse listing rather than 404 when nobody has dropped files yet.

3. Fixture seeded with 3 files per party under incoming/ — naming
   intentionally NOT in transmittal-envelope form, so classifier
   (loaded automatically by browse's grid mode at /incoming/
   per the URL-driven view convention) has something to rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:36:03 -05:00
e85d5fc660 feat(zddc): canonical lowercase + .zddc display map + archive project titles
User report: project root listings showed both "Archive" (PascalCase on
disk) and "archive (empty)" (lowercase virtual) — confusing duplicates.
This sweep:

1. Test fixture migrated to lowercase canonical folder names.
   tests/data/test-archive.sh now creates archive/, received/, issued/
   on disk. Three projects also get human-friendly .zddc titles
   ("Wabash Industrial Refit — Phase 1", etc.), and Project-3 carries
   a display: override demonstrating the new map. Party names
   (PartyA/B/C) stay unchanged — non-canonical.

2. New .zddc display: schema. Maps a child entry's on-disk name to a
   human-friendly label. The on-disk name stays canonical (lowercase
   for project-root folders); only the rendered label changes. Match
   is case-insensitive. Example:

     display:
       archive:   "Records"
       working:   "In-Progress"

   No upward cascade — a parent .zddc doesn't relabel grand-children;
   each directory sets display: on its own children.

3. listing.FileInfo gets a DisplayName field. fs.ListDirectory reads
   the directory's .zddc display map and stamps DisplayName per entry.
   The field is omitempty so listings without overrides stay
   byte-identical to before.

4. Virtual canonical project-root folders (archive/working/staging/
   reviewing) are now emitted by zddc-server (fs.ListDirectory) at any
   project root where the on-disk variant is absent in any case. This
   replaces the client-side injection in browse and lets the display:
   map apply to virtual entries the same way it applies to real ones.
   Browse drops its withVirtualCanonicals helper; the loader carries
   display_name through from the server's listing.

5. Archive app project picker dropdown shows the .zddc title of each
   project (sourced from ProjectInfo.Title in the server's project
   list), falling back to the folder name when no title is set. When
   they differ, the folder name is rendered in muted mono after the
   title for traceability. data-name still carries the canonical
   folder name so URL state stays stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:03:53 -05:00
ee67b9e596 fix(zddc-server): mdl slash form serves browse; .zddc viewable at every depth
Two related routing fixes:

1. /<project>/archive/<party>/mdl[/] now follows the slash/no-slash
   convention uniformly with the rest of the system:

     - mdl  (no slash) → tables app (default tool for mdl/)
     - mdl/ (slash)    → browse (ServeDirectory empty-listing fallback)

   Previously the slash form auto-redirected to mdl/table.html, which
   forced the user into the table view from any party-folder click and
   produced a confusing "Unrecognized table URL" error when the
   redirect race-conditioned. tableRowsRedirect now only redirects
   when a real on-disk table.yaml exists; the default-MDL virtual case
   stays in browse via the convention.

   New zddc.IsArchivePartyMdlDir helper recognises the canonical
   <project>/archive/<party>/mdl pattern at depth 4 (relative path).
   fs.ListDirectory uses it to return [] for the missing-on-disk case
   so browse renders the empty workspace cleanly. Test updated
   (TestServeDirectoryRedirectsDefaultMdl → TestServeDirectoryDefaultMdlNoRedirect).

2. <dir>/.zddc URLs now work at every directory depth.

   The dispatcher previously 404'd anything beginning with a dot
   (except /.archive and /<dir>/.zddc.html). New IsZddcFileRequest +
   ServeZddcFile handlers carve out the raw .zddc leaf so an operator
   can navigate to /Project-1/archive/PartyA/mdl/.zddc and inspect
   the rules effective at that depth.

   Semantics:
     - Method: GET / HEAD only. Writes go through the existing admin-
       gated form at <dir>/.zddc.html (unchanged).
     - ACL:    parent directory's read permission gates access; 404
       (not 403) is returned to non-readers so existence isn't leaked.
     - On disk: file bytes served verbatim with
       Content-Type: application/yaml and X-ZDDC-Source: file:<rel>.
     - Virtual: when no file exists at this level, a synthetic
       placeholder body is returned with a YAML-comment cascade
       summary so the reader sees exactly what rules apply here from
       ancestors. X-ZDDC-Source: virtual:zddc distinguishes it.

   The virtual body parses as valid YAML (`{}` after the comments) so
   downstream tooling that consumes the URL isn't confused.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:45:16 -05:00
41e6576111 fix(zddc-server): canonical-folder fallback also at the dispatcher
The previous fix in fs.ListDirectory was insufficient — main.go's
dispatcher calls os.Stat(absPath) before reaching ServeDirectory,
and 404s on the missing path before the listing code ever runs.
Symptom: GET <project>/working/ on a fresh project still returned
"Not Found" despite the read-side fallback being committed.

Add the same fallback at the dispatcher level: when os.Stat returns
NotExist AND the URL ends with "/" AND the path matches
IsProjectRootFolder, fall through to ServeDirectory rather than
404. ServeDirectory's ACL check + ListDirectory's empty-listing
behavior take it from there.

Separately, fs.ListDirectory now initializes its result slice to
make([]listing.FileInfo, 0) instead of `var result []listing.FileInfo`,
so the JSON encoder emits "[]" rather than "null" for empty
listings — clients (browse, archive) expect an array and choke on
null.

New test TestDispatchEmptyCanonicalProjectFolders covers the four
canonical names (archive/working/staging/reviewing) on a project
where none of them exist on disk yet, plus the negative case (a
non-canonical missing path still 404s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:59:30 -05:00
3fc371752a feat(zddc-server): empty listing for canonical project folders
Listing <project>/{archive,working,staging,reviewing}/ when the folder
doesn't exist on disk now returns an empty 200 listing instead of 404.
The stage-strip nav links into these folders unconditionally; without
this fallback, clicking "Working" against a fresh project (where
working/ hasn't been written to yet) lands on a 404 page rather than
a usable empty view.

Mechanism stays consistent with the existing lazy-folder design:
  - GET on missing canonical folder → 200 + empty listing (this commit)
  - first WRITE under the same path → EnsureCanonicalAncestors
    materialises the on-disk folder + auto-own .zddc

reviewing/ stays virtual-only (in VirtualOnlyCanonicalNames); the
fallback just makes its empty listing always renderable. The future
reviewing/ aggregator (recorded in project memory) will replace the
empty listing with the join-computed virtual entries.

The fallback is gated on IsProjectRootFolder — only depth-2 paths
matching one of the four canonical names. Non-canonical missing paths
still 404 (TestListDirectory_NonCanonicalMissing_StillNotFound).

For working/ specifically the synthetic <viewer-email>/ home entry
still fires from virtualUserHomeEntry, so the user sees their own
placeholder even when working/ doesn't exist yet — first write into
that placeholder triggers the lazy-create chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:34:53 -05:00
ce108e1eb3 feat(fs): synthesise per-user virtual home in working/ listings
ListDirectory now appends a synthetic <viewer-email>/ entry when the
listed path is exactly <project>/working/ (depth 2, case-fold) and no
real directory there matches the viewer's email under any case.

The entry has IsDir=true and a new Virtual=true flag on
listing.FileInfo (omitempty in JSON so existing clients that don't
know the field continue to render it as a regular folder). A first
write to that path materialises a real folder via the existing
auto-own pipeline (EnsureCanonicalAncestors → WriteAutoOwnZddc),
after which subsequent listings drop the synthetic entry naturally.

Anonymous viewers, listings outside working/, and listings inside a
deeper working/ subdirectory all skip the synthetic entry.

Six tests cover: appears-when-missing, suppressed-when-real-exists
(case-fold), anonymous-no-entry, staging/-no-entry, deep-working-no-
entry, and pre-existing-PascalCase-Working/ still triggers it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:20:25 -05:00
e911806eda feat(server): pluggable OPA-compatible policy decider
Add an internal access-decision boundary that all handlers go through
instead of calling zddc.AllowedWithChain directly. Two implementations
ship:

  * InternalDecider — wraps the existing zddc.AllowedWithChain. The
    default. No new dependencies, identical semantics to the legacy
    code path. ZDDC_OPA_URL=internal (or unset).

  * HTTPDecider — POSTs the canonical OPA wire format
    (POST /v1/data/zddc/access/allow with {"input": {...}}, response
    {"result": true|false}) over HTTP, HTTPS, or a Unix-domain socket.
    For federal customers running their own audited Rego policies
    alongside zddc-server. ZDDC_OPA_URL=http(s)://… or unix:///….

External-mode failure semantics: unreachable / non-2xx / malformed
response → fail closed (deny) by default with a WARN log. Operators
who prefer availability over correctness flip with ZDDC_OPA_FAIL_OPEN=1.

The decider is constructed once at startup, plumbed through ACLMiddleware
into the request context. Handlers retrieve it via DeciderFromContext;
non-request callers (fs.ListDirectory, EnumerateProjects, enumerateAccess)
take it as an explicit parameter.

zddc.ZddcFile and zddc.ACLRules gain JSON tags so external Rego authors
get idiomatic input shape (acl.allow, admins, …) instead of Go field
names (ACL.Allow, Admins, …).

Test coverage:
  * InternalDecider parity tests against zddc.AllowedWithChain (every
    documented cascade scenario: empty chain, leaf-allow-wins, leaf-
    deny-beats-parent, leaf-allows-what-parent-denies, deepest-match-
    wins, etc.)
  * HTTPDecider happy-path test (canonical wire format)
  * Fail-closed / fail-open / malformed-response tests

Production binary size unchanged (no new deps; HTTP transport is
stdlib net/http). 11 ACL call sites migrated. End-to-end verified
against the worked-example layout in zddc/README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:45:07 -05:00
ea385b5366 Initial commit
ZDDC — Zero Day Document Control. A file-naming convention plus five
single-file HTML tools (archive, transmittal, classifier, mdedit,
landing) and an optional Go HTTP server (zddc-server) with ACL and a
virtual archive index. Self-contained, offline-capable, dependency-free.

See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the
build/release/architecture detail, bootstrap/README.md for the
two-level deployment install pattern, and zddc/README.md for the
HTTP server.
2026-04-27 11:05:47 -05:00