Documentation
¶
Overview ¶
claim_assets.go is the ONE route in this server that reads a file off disk and sends its bytes back, and it is built so that the reason it is safe is structural rather than procedural.
WHY A ROUTE EXISTS AT ALL. "dossierx serve" is the only human surface — the standalone render verb was retired in v0.3.0 and nothing writes static HTML — so a claim-body image that cannot load under serve is the feature not working. The two fast ways to make one load are "img-src *" and an http.FileServer rooted at the project, and each demolishes one of the two controls that make this server safe today. Neither is here.
WHAT MAKES IT SAFE: THE SET OF LEGAL PATHS IS COMPUTED, NOT DISCOVERED. Every legal image is an assets/ file referenced by a claim body the engine has already loaded, so the allowlist is derivable — run the SAME renderer over the same claims and collect what it accepted (markdown.ClaimBodyImages), key each one by the SAME URL the page emits (components.ClaimAssetURLPrefix). A file nothing references is not reachable, even when it sits in a legal assets/ directory beside a real claim with a legal extension. That is the whole control, and it is why the co-location rule earns its keep: without it the legal set would be "some subtree", which is a filesystem question, and answers to filesystem questions are how directory traversal happens.
"WHAT THE RENDERER EMITS" IS A NARROWER SET THAN "WHAT THE CLAIM CONTAINS", and the index has to be built from the narrow one or the sentence above is false. Three things make them differ, and all three are handled in buildAssetIndex rather than left to the request path:
- THE PERMISSION IS PER-PARTIAL. layout:tree renders Body raw inside a <pre> with no markdown at all; every layout but steps ignores Steps entirely. So the index asks components.ClaimImageSurfaces which of a claim's fields its partial actually renders, instead of assuming Body and Steps always count.
- THE LAYOUT MAY BE INFERRED. loader.LoadClaims does not fill it in; internal/catalog does, and the page is rendered from the catalog. The index is built from the catalog for the same reason.
- AN ID MAY NOT BE UNIQUE. The URL is keyed by claim id, so two claims that share one collapse onto a single key — and "first writer wins" does not merely pick a winner, it points the LOSER'S page at the winner's file. An ambiguous id therefore loses the image capability outright, which is the same degradation an unroutable id already gets.
AND THE INDEX MUST NOT OUTLIVE THE CLAIM. Freshness is a fingerprint comparison, so the fingerprint has to enumerate exactly the files the loader reads — see scanLoadedClaimFingerprint, and see scanFingerprint for why the watcher's narrower scan is not usable here. That comparison is AMORTISED rather than made per request; claimAssets owns that argument and states the staleness window it buys.
THE PATH IS DEFENDED ANYWAY, independently of the allowlist, because an allowlist entry is only as good as the claim that produced it and a file on disk can change after the entry was made:
- Every path in the index is built under the CANONICALISED claims_dir, so "inside claims_dir" is a property of how it was constructed.
- At request time the file is canonicalised again and must still equal its own lexical path. That is what catches a symlink — a legitimately referenced assets/diagram.png that is a link out of the tree passes every other check there is.
- Only a regular file is opened, so a directory is never listed and never read.
- The Content-Type comes from a closed switch over the six legal extensions, never from sniffing, and the response carries nosniff plus its own restrictive CSP — an SVG is a document a browser will run script in if it is ever navigated to directly.
EVERY REFUSAL IS 404. Not 403, and never 403-with-a-reason: a status that distinguished "exists but forbidden" from "does not exist" would be an existence oracle over the filesystem, answerable from any page the admission middleware lets through.
Package serve is the local HTTP server behind "dossierx serve": it renders the claims viewer from memory and exposes a same-origin JSON write API for the "comments on claims" feature, so a human can review claims in a browser while agents drive the CLI.
Security posture. The server binds 127.0.0.1 on a random high port and every request passes through the admission middleware (see middleware.go) BEFORE any handler runs. That middleware is the whole trust boundary: a Host allowlist is the sole DNS-rebinding defense, an Origin allowlist plus a Content-Type check plus a Sec-Fetch-Site check gate every mutating method, and NO CORS header is ever emitted. The API is unauthenticated by design (a single local user), so the admission rules — not credentials — are what stop a random web page from driving the comment API.
Locking. serve never mutates claim files itself: every write goes through internal/comments, which already owns the project-wide claims sentinel (one op = one AcquireClaimsLock -> load -> mutate -> SaveClaim -> release). The GET / render pipeline runs lock-free and reads from disk, so a page load never blocks (or is blocked by) a concurrent comment write. On SIGINT/SIGTERM the command context is cancelled and Serve does a graceful http.Server Shutdown, which waits for in-flight handlers so any comment op mid-write runs its deferred lock release before the process exits (a bare signal death would skip that defer).
Index ¶
- type Server
- func (s *Server) AssetTreeScans() int64
- func (s *Server) HubSize() int
- func (s *Server) Listen(port int) error
- func (s *Server) Port() int
- func (s *Server) ReadOnly() bool
- func (s *Server) RenderRuns() int64
- func (s *Server) Serve(ctx context.Context) error
- func (s *Server) SetWarnWriter(w io.Writer)
- func (s *Server) SetWatchIntervals(poll, debounce time.Duration)
- func (s *Server) URL() string
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server is one "dossierx serve" instance: the viewer render pipeline plus the comment JSON API for a single project. It is constructed with New, bound to a port with Listen, and driven by Serve. The zero value is not usable — always go through New.
func New ¶
New builds a Server for cfg. version is reported verbatim by GET /api/ping (the reachability probe the viewer uses to decide whether the write controls mount); the caller resolves it (cmd/dossierx passes resolveVersionInfo's value). New does not bind a port — call Listen next.
func (*Server) AssetTreeScans ¶ added in v0.3.1
AssetTreeScans is the number of times the claim-image allowlist has stat-walked the claims tree to verify its own freshness. It exists for the one test that can catch a return to per-request walking — the walk is O(claims) and a page fires one asset request per image, so N requests against an unchanged tree must produce a bounded number of scans, never N. Production code has no reason to call it.
func (*Server) HubSize ¶
HubSize reports the number of live /api/events subscribers. It exists for tests to prove a disconnect drops the subscription (a leak -race cannot see); production code has no reason to call it.
func (*Server) Listen ¶
Listen binds the server to 127.0.0.1:port (port 0 selects a random high port, the default). It must be called once, before Serve. The bound port is what the admission middleware validates every request's Host against, so it is recorded here for that check.
func (*Server) ReadOnly ¶
ReadOnly reports whether serve is running in read-only mode because the effective shell.html lacks the live-viewer runtime marker (see applyViewerRuntimeMode). Meaningful only after Serve has started; the mutating comment endpoints consult the same flag to refuse writes with 403 read_only.
func (*Server) RenderRuns ¶
RenderRuns is the number of viewer renders the pipeline has executed. It exists for tests to prove single-flight coalescing (N concurrent GET / requests run the pipeline fewer than N times); production code has no reason to call it.
func (*Server) Serve ¶
Serve runs the HTTP server until ctx is cancelled (the command wires ctx to SIGINT/SIGTERM), then gracefully drains in-flight requests — letting any comment op mid-write run its deferred claims-lock release — before returning. It returns nil on a clean shutdown and the listener error otherwise. Listen must have been called first.
func (*Server) SetWarnWriter ¶
SetWarnWriter redirects the startup viewer-degradation WARNING away from os.Stderr, so a test can assert whether the warning fired. It MUST be called before Serve (which writes the warning during startup); production code keeps the New default (os.Stderr) and never calls this.
func (*Server) SetWatchIntervals ¶
SetWatchIntervals overrides the watcher's poll and debounce cadence. It lets tests drive live reload on a short, deterministic cycle instead of the ~500ms/200ms production defaults; it MUST be called before Serve, which reads the values when it starts the watcher. Production code keeps the New defaults and never calls this.